From 8b8c5972f1df5fa4bb3e92a34a0ecd145f5a3254 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:05:49 +0100 Subject: [PATCH 01/28] .NET: Add OpenAI Responses protocol helpers and optional execution state (ADR-0032) --- .../0032-dotnet-hosting-protocol-helpers.md | 148 ++++++++++++ .../003-dotnet-hosting-protocol-helpers.md | 219 ++++++++++++++++++ dotnet/agent-framework-dotnet.slnx | 2 + .../HostingResponsesAgent.csproj | 23 ++ .../HostingResponsesAgent/Program.cs | 74 ++++++ .../HostingResponsesAgent/README.md | 44 ++++ .../HostingResponsesWorkflow.csproj | 24 ++ .../HostingResponsesWorkflow/Program.cs | 67 ++++++ .../HostingResponsesWorkflow/README.md | 45 ++++ .../OpenAIResponses.cs | 136 +++++++++++ .../OpenAIResponsesRunRequest.cs | 36 +++ .../AgentSessionStore.cs | 18 ++ .../DelegatingAgentSessionStore.cs | 4 + .../HostedAgentState.cs | 143 ++++++++++++ .../HostedWorkflowRunResult.cs | 34 +++ .../HostedWorkflowState.cs | 103 ++++++++ .../IsolationKeyScopedAgentSessionStore.cs | 7 + .../Local/InMemoryAgentSessionStore.cs | 7 + .../NoopAgentSessionStore.cs | 6 + .../OpenAIResponsesTests.cs | 84 +++++++ .../HostedAgentStateTests.cs | 129 +++++++++++ .../HostedWorkflowStateTests.cs | 75 ++++++ .../InMemoryAgentSessionStoreTests.cs | 85 +++++++ 23 files changed, 1513 insertions(+) create mode 100644 docs/decisions/0032-dotnet-hosting-protocol-helpers.md create mode 100644 docs/specs/003-dotnet-hosting-protocol-helpers.md create mode 100644 dotnet/samples/04-hosting/HostingResponsesAgent/HostingResponsesAgent.csproj create mode 100644 dotnet/samples/04-hosting/HostingResponsesAgent/Program.cs create mode 100644 dotnet/samples/04-hosting/HostingResponsesAgent/README.md create mode 100644 dotnet/samples/04-hosting/HostingResponsesWorkflow/HostingResponsesWorkflow.csproj create mode 100644 dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs create mode 100644 dotnet/samples/04-hosting/HostingResponsesWorkflow/README.md create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponses.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesRunRequest.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowRunResult.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentStateTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs diff --git a/docs/decisions/0032-dotnet-hosting-protocol-helpers.md b/docs/decisions/0032-dotnet-hosting-protocol-helpers.md new file mode 100644 index 00000000000..fa0585732be --- /dev/null +++ b/docs/decisions/0032-dotnet-hosting-protocol-helpers.md @@ -0,0 +1,148 @@ +--- +status: accepted +contact: rogerbarreto +date: 2026-07-08 +deciders: rogerbarreto +consulted: eavanvalkenburg +informed: [] +--- + +# .NET hosting: OpenAI Responses protocol helpers for app-owned routing + +Realizes the helper-first direction of [ADR-0027](0027-hosting-channels.md) for .NET. + +## Context and Problem Statement + +[ADR-0027](0027-hosting-channels.md) refocused the (Python) hosting design away from a channel +framework toward **protocol conversion helpers plus optional execution state**: Agent Framework owns +protocol-native <-> run conversion, while the application owns HTTP routing, authentication, +middleware, storage, and native SDK calls. + +.NET already ships `Microsoft.Agents.AI.Hosting.OpenAI`, a route-owning server that **exposes an +`AIAgent` (or workflow) as the OpenAI Responses API** (`MapOpenAIResponses` + `IResponsesService`). It +owns the routes, an in-memory response/conversation store, streaming, and lifecycle. The question is +what, if anything, .NET must add to satisfy the ADR-0027 boundary. + +## Decision Drivers + +- Do not reinvent conversion logic that already exists and is battle-tested in `Hosting.OpenAI`. +- Give applications a way to own their own route/auth/middleware/storage while reusing Agent Framework + conversion (the ADR-0027 boundary). +- Keep the released public surface small. +- Stay consistent with the existing .NET hosting stack, which deliberately does **not** use the OpenAI + SDK Responses types server-side (it hand-rolled its own wire model). + +## Considered Options + +1. Self-contained new package that reimplements conversion using the OpenAI SDK Responses types + (mirrors the Python `agent-framework-hosting-responses` lineage). +2. New package that reuses `Hosting.OpenAI`'s internal converters (via `InternalsVisibleTo` or by + moving the conversion core out). +3. Thin public helper facade **inside** `Hosting.OpenAI` over the existing internal converters, plus + protocol-neutral execution-state holders in `Microsoft.Agents.AI.Hosting`. + +### First-principles gap analysis + +A capability comparison of the ADR-0027 / PR #6891 helper surface against the existing .NET stack: + +| Python helper capability | .NET today | Status | +| --- | --- | --- | +| `responses_to_run` | `ResponseInput.GetInputMessages` + `InputMessage.ToChatMessage` + `OpenAIResponsesMapOptions.RunOptionsFactory` | exists, internal | +| `responses_from_run` | `AgentResponseExtensions.ToResponse` | exists, internal | +| `responses_from_streaming_run` | `AgentResponseUpdateExtensions.ToStreamingResponseAsync` + `SseJsonResult` (also renders workflow events) | exists, internal, richer | +| `responses_session_id` | continuity resolved inside `InMemoryResponsesService` | exists, internal, not standalone | +| `create_response_id` | `IdGenerator` | exists, internal | +| `AgentState` (target + store, get-or-create) | `AgentSessionStore` (get-or-create + save + serialize + isolation) | richer; missing `Delete` + per-session lock | +| `SessionStore` (get/set/delete) | `AgentSessionStore` + `InMemoryAgentSessionStore` | richer; no `Delete` | +| `WorkflowState` + checkpoint resume | `WorkflowCatalog`/`HostedWorkflowBuilder`; workflow events already render over Responses; `CheckpointManager` is session-keyed | partial; no per-session checkpoint cursor | +| App owns routing/auth/middleware/storage | `MapOpenAIResponses`/`IResponsesService` own routing + storage | **the one real gap** | + +.NET already covers ~90% of the capability, and more richly (its streaming renderer even emits workflow +events; its session store serializes and supports per-principal isolation, neither of which Python's +in-memory `SessionStore` does). The single genuine gap is the **ownership model**: every conversion +primitive is bundled behind the route-owning server, so an application cannot own its own route and +call just the conversion. + +Note on lineage: Python's Responses offering was introduced *as a channel* (PR #6580) and always used +the `openai` SDK Responses types. .NET's `Hosting.OpenAI` predates and is independent of channels and +hand-rolled its own server-side wire DTOs (the SDK's Responses types are client-shaped and awkward +server-side). So Option 1 would both reinvent a working asset and contradict the .NET codebase's own +precedent. + +## Decision Outcome + +Chosen option: **3. Thin public helper facade inside `Hosting.OpenAI` plus neutral state holders**, +because the only real gap is the ownership model, so the work is to *un-bundle* the existing +converters, not to rebuild them or add a package. + +### Public surface + +`Microsoft.Agents.AI.Hosting.OpenAI` gains a single public static facade, `OpenAIResponses`, whose +boundary is `System.Text.Json` (`JsonElement`/streamed events), matching Python's dict boundary and +keeping the hand-rolled wire DTOs internal: + +- `OpenAIResponses.ToAgentRunRequest(JsonElement body)` -> messages + `AgentRunOptions?`. +- `OpenAIResponses.WriteResponse(AgentRunResponse response, string responseId, string? sessionId = null)` + -> a Responses-shaped `JsonElement`. +- `OpenAIResponses.WriteResponseStreamAsync(IAsyncEnumerable updates, string responseId, ...)` + -> Responses SSE `data:` frames. +- `OpenAIResponses.GetSessionId(JsonElement body)` -> `previous_response_id` or `conversation` id, or + `null`. Kept **separate** from `ToAgentRunRequest` so the trust boundary is visible: choosing to use + a request-derived key is an explicit application decision. +- `OpenAIResponses.CreateResponseId()` -> a `resp_*` id. + +All helpers are side-effect-free and delegate to the existing internal converters. `MapOpenAIResponses` +public behavior is unchanged; it and the facade share one internal conversion core (an internal +`ToResponse` overload with an optional originating request is added so the facade can render without a +request object). + +### Optional execution state (neutral package) + +`Microsoft.Agents.AI.Hosting` gains: + +- `AgentSessionStore.DeleteSessionAsync(...)` (+ `InMemoryAgentSessionStore` implementation and + isolation-decorator passthrough): the one missing store operation. +- `HostedAgentState`: a thin holder bundling an `AIAgent` with an `AgentSessionStore`, exposing + `GetOrCreateSessionAsync`, `SaveSessionAsync` (including under a newly minted id), and + `DeleteSessionAsync`, with optional per-session locking. It exists because only the holder has both + the target and the store; it does not replace `AgentSessionStore`, which already provides + serialization and isolation that Python's `AgentState`/`SessionStore` lack. +- `HostedWorkflowState`: a thin holder bundling a workflow target with a `CheckpointManager` and an + internal `sessionId -> CheckpointInfo` head cursor, exposing `RunOrResumeAsync`. .NET's checkpoint + store is already `sessionId`-keyed (unlike Python's workflow-name keying), but `CheckpointInfo` has + no ordering, so the holder remembers the head checkpoint per session to resume. + +### Scope + +Responses only for v1; the facade is named so a parallel `OpenAIChatCompletions` facade can follow. +No new package, no OpenAI-SDK-typed reimplementation, no change to `MapOpenAIResponses` public +behavior. + +### Security responsibilities + +Consistent with ADR-0027, the application owns the trust boundary. `GetSessionId(...)` returns an +untrusted candidate key; the application must authenticate the caller and authorize/bind the id before +using it as an `AgentSessionStore` key or workflow checkpoint session id. Multi-user hosts must scope +the session store per principal (`IsolationKeyScopedAgentSessionStore`). Helpers stay side-effect-free; +persistence happens only after the run completes. + +## Consequences + +Positive: + +- Smallest possible surface: the released addition is one facade type plus two thin state holders and + one new store method. +- No duplicated conversion; the app-owned-routing path and the route-owning server share one core. +- `MapOpenAIResponses` users are unaffected. + +Negative: + +- The facade's `JsonElement` boundary is less strongly typed than the internal DTOs (accepted to keep + the wire model internal and mirror Python's dict boundary). +- Workflow resume relies on an in-memory head cursor by default; durable multi-replica hosts must + supply their own cursor persistence. + +## More Information + +- Parent ADR: [ADR-0027](0027-hosting-channels.md). +- Spec: `docs/specs/003-dotnet-hosting-protocol-helpers.md`. diff --git a/docs/specs/003-dotnet-hosting-protocol-helpers.md b/docs/specs/003-dotnet-hosting-protocol-helpers.md new file mode 100644 index 00000000000..975650de1fa --- /dev/null +++ b/docs/specs/003-dotnet-hosting-protocol-helpers.md @@ -0,0 +1,219 @@ +--- +status: accepted +contact: rogerbarreto +date: 2026-07-08 +deciders: rogerbarreto +consulted: eavanvalkenburg +informed: [] +--- + +# .NET hosting: OpenAI Responses protocol helpers and optional execution state + +Implements [ADR-0032](../decisions/0032-dotnet-hosting-protocol-helpers.md), which realizes the +helper-first direction of [ADR-0027](../decisions/0027-hosting-channels.md) for .NET. + +## What is the goal of this feature? + +Let application developers expose an `AIAgent` or workflow over the OpenAI Responses protocol **while +owning their own ASP.NET Core route, authentication, middleware, and storage**, by calling small, +side-effect-free Agent Framework conversion helpers instead of adopting the batteries-included, +route-owning `MapOpenAIResponses` server. + +Success: an application can implement a working `POST /responses` endpoint (sync + streaming) in its +own minimal-API handler using only the public helpers plus its own auth/storage, with no dependency on +`MapOpenAIResponses` or `IResponsesService`. + +## What is the problem being solved? + +.NET already exposes agents as the OpenAI Responses API, but only through the route-owning +`MapOpenAIResponses`/`IResponsesService`, which also owns routing, response/conversation storage, +streaming, and lifecycle. An application that wants its own routing (custom auth, middleware, status +codes, durable storage, or a different framework surface) currently has no supported way to reuse the +framework's Responses<->agent conversion. Every conversion primitive that would make this possible +already exists in `Microsoft.Agents.AI.Hosting.OpenAI` but is `internal`. + +This feature un-bundles that conversion into a public, app-callable surface, and adds the minimal +execution-state helpers an app needs for session continuity and workflow checkpoint resume. + +## API Changes + +### `Microsoft.Agents.AI.Hosting.OpenAI` (new public static facade `OpenAIResponses`) + +Boundary is `System.Text.Json`; the wire DTOs stay internal. All members are side-effect-free. + +```csharp +namespace Microsoft.Agents.AI.Hosting.OpenAI; + +public static class OpenAIResponses +{ + // Wire -> Agent Framework run input. + public static OpenAIResponsesRunRequest ToAgentRunRequest( + JsonElement body, + OpenAIResponsesMapOptions? mapOptions = null); + + // Agent Framework result -> Responses payload (no originating request required). + public static JsonElement WriteResponse( + AgentResponse response, + string responseId, + string? sessionId = null); + + // Agent Framework stream -> Responses SSE `data:` frames. + public static IAsyncEnumerable WriteResponseStreamAsync( + IAsyncEnumerable updates, + string responseId, + string? sessionId = null, + CancellationToken cancellationToken = default); + + // Untrusted candidate continuation key: previous_response_id or conversation id (or null). + // Kept SEPARATE from ToAgentRunRequest so using a request-derived key is an explicit decision. + public static string? GetSessionId(JsonElement body); + + // Mint a `resp_*` id. + public static string CreateResponseId(); +} + +// Result of ToAgentRunRequest. +public sealed class OpenAIResponsesRunRequest +{ + public IList Messages { get; } + public AgentRunOptions? Options { get; } +} +``` + +`ToAgentRunRequest` honors `OpenAIResponsesMapOptions.RunOptionsFactory` exactly as the route model +does (by default no request setting is mapped onto the run; unsupported settings surface as a +`NotSupportedException`). `WriteResponse`/`WriteResponseStreamAsync` reuse the existing internal +`AgentResponseExtensions.ToResponse` / `AgentResponseUpdateExtensions.ToStreamingResponseAsync` +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) + +```csharp +namespace Microsoft.Agents.AI.Hosting; + +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); +} + +// Thin holder: pairs an agent target with a session store. +public sealed class HostedAgentState +{ + public HostedAgentState(AIAgent agent, AgentSessionStore? sessionStore = null); + + public ValueTask GetOrCreateSessionAsync(string sessionId, CancellationToken ct = default); + public ValueTask SaveSessionAsync(string sessionId, AgentSession session, CancellationToken ct = default); + public ValueTask DeleteSessionAsync(string sessionId, CancellationToken ct = default); +} + +// Thin holder: pairs a workflow target with checkpointing + a per-session head cursor. +public sealed class HostedWorkflowState +{ + public HostedWorkflowState(Workflow workflow, CheckpointManager? checkpointManager = null); + + // Runs forward, or resumes from the session's last checkpoint if one is recorded, + // then records the new head checkpoint for the session. + public ValueTask RunOrResumeAsync( + string sessionId, object input, CancellationToken ct = default); +} +``` + +`HostedAgentState.GetOrCreateSessionAsync` delegates to `AgentSessionStore.GetSessionAsync(agent, id)` +(which already creates on miss). `SaveSessionAsync(id, session)` persists post-run, including under a +newly minted `resp_*` id when the protocol mints a new continuation id. Optional per-session locking +serializes concurrent first-touch of the same id. `DeleteSessionAsync` uses the new store method. + +`HostedWorkflowState` defaults to `CheckpointManager.CreateInMemory()` and an in-memory +`sessionId -> CheckpointInfo` cursor. Because the checkpoint store is already `sessionId`-keyed but +`CheckpointInfo` carries no ordering, the holder remembers the head checkpoint per session so +`RunOrResumeAsync` can resume the correct one. Durable/multi-replica hosts supply their own +`CheckpointManager` and (later) cursor persistence. + +## Non-goals for v1 + +- ChatCompletions / Conversations helper surfaces (the facade is named so `OpenAIChatCompletions` can + follow). +- Changing `MapOpenAIResponses` public behavior. +- A new package or an OpenAI-SDK-typed reimplementation. +- Durable/pluggable workflow checkpoint-cursor storage (in-memory default only for v1). + +## Security responsibilities (application-owned) + +- 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 + `UseClaimsBasedSessionIsolation(...)`), so the session namespace is scoped per principal. +- Persist session/checkpoint state only after the run or stream has completed. + +## E2E Code Samples + +### Agent over Responses, app-owned route (non-streaming + SSE) + +```csharp +var agent = /* an AIAgent */; +var state = new HostedAgentState(agent); // in-memory session store by default + +app.MapPost("/responses", async (HttpContext http, CancellationToken ct) => +{ + using var doc = await JsonDocument.ParseAsync(http.Request.Body, cancellationToken: ct); + JsonElement body = doc.RootElement; + + // App owns auth + id trust decisions. + string? candidate = OpenAIResponses.GetSessionId(body); + string sessionId = Authorize(http.User, candidate) ?? OpenAIResponses.CreateResponseId(); + + var run = OpenAIResponses.ToAgentRunRequest(body); + var session = await state.GetOrCreateSessionAsync(sessionId, ct); + + string responseId = OpenAIResponses.CreateResponseId(); + + if (body.TryGetProperty("stream", out var s) && s.GetBoolean()) + { + http.Response.ContentType = "text/event-stream"; + var updates = agent.RunStreamingAsync(run.Messages, session, run.Options, ct); + await foreach (var frame in OpenAIResponses.WriteResponseStreamAsync(updates, responseId, sessionId, ct)) + { + await http.Response.WriteAsync(frame, ct); + await http.Response.Body.FlushAsync(ct); + } + await state.SaveSessionAsync(responseId, session, ct); + return Results.Empty; + } + + var result = await agent.RunAsync(run.Messages, session, run.Options, ct); + await state.SaveSessionAsync(responseId, session, ct); + return Results.Json(OpenAIResponses.WriteResponse(result, responseId, sessionId)); +}); +``` + +### Workflow over Responses with checkpoint resume + +```csharp +var state = new HostedWorkflowState(workflow); // in-memory checkpoints + cursor + +app.MapPost("/responses", async (HttpContext http, CancellationToken ct) => +{ + using var doc = await JsonDocument.ParseAsync(http.Request.Body, cancellationToken: ct); + JsonElement body = doc.RootElement; + + string sessionId = Authorize(http.User, OpenAIResponses.GetSessionId(body)) + ?? OpenAIResponses.CreateResponseId(); + + var run = OpenAIResponses.ToAgentRunRequest(body); + + // Runs forward on first call, resumes from the session's head checkpoint thereafter. + var result = await state.RunOrResumeAsync(sessionId, run.Messages, ct); + + return Results.Json(OpenAIResponses.WriteResponse(result.AsAgentResponse(), + OpenAIResponses.CreateResponseId(), sessionId)); +}); +``` diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 27cf8605740..b3d08a874ab 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -314,6 +314,8 @@ + + diff --git a/dotnet/samples/04-hosting/HostingResponsesAgent/HostingResponsesAgent.csproj b/dotnet/samples/04-hosting/HostingResponsesAgent/HostingResponsesAgent.csproj new file mode 100644 index 00000000000..ccb53854b1f --- /dev/null +++ b/dotnet/samples/04-hosting/HostingResponsesAgent/HostingResponsesAgent.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + HostingResponsesAgent + HostingResponsesAgent + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/HostingResponsesAgent/Program.cs b/dotnet/samples/04-hosting/HostingResponsesAgent/Program.cs new file mode 100644 index 00000000000..0401a230111 --- /dev/null +++ b/dotnet/samples/04-hosting/HostingResponsesAgent/Program.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how an application can own its own ASP.NET Core route and expose an AIAgent over the +// OpenAI Responses protocol by calling the Agent Framework OpenAIResponses conversion helpers, instead of +// using the batteries-included MapOpenAIResponses server. The application keeps control of routing, auth, +// and session storage; the helpers provide only the protocol <-> agent conversion. + +using System.Text.Json; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Hosting.OpenAI; +using OpenAI.Chat; + +var builder = WebApplication.CreateBuilder(args); + +// Configuration via environment variables (never hardcode secrets). +string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") ?? "gpt-4o-mini"; + +AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetChatClient(deployment) + .AsAIAgent(instructions: "You are a helpful assistant.", name: "Assistant"); + +// Optional shared execution state: pairs the agent with a session store (in-memory by default). +var state = new HostedAgentState(agent); + +var app = builder.Build(); + +// The application owns this route. It parses the OpenAI Responses body with the helpers, runs the agent +// itself, and renders the response with the helpers. +app.MapPost("/responses", async (HttpContext http, CancellationToken cancellationToken) => +{ + using JsonDocument doc = await JsonDocument.ParseAsync(http.Request.Body, cancellationToken: cancellationToken).ConfigureAwait(false); + JsonElement body = doc.RootElement; + + // The candidate continuation id is untrusted. A real app authenticates the caller and authorizes/binds + // this key to the principal before using it. This sample simply falls back to a fresh id. + string? candidateSessionId = OpenAIResponses.GetSessionId(body); + string sessionId = Authorize(http, candidateSessionId) ?? OpenAIResponses.CreateResponseId(); + + OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); + AgentSession session = await state.GetOrCreateSessionAsync(sessionId, cancellationToken).ConfigureAwait(false); + string responseId = OpenAIResponses.CreateResponseId(); + + bool stream = body.TryGetProperty("stream", out JsonElement streamProp) && streamProp.ValueKind == JsonValueKind.True; + + if (stream) + { + http.Response.ContentType = "text/event-stream"; + var updates = agent.RunStreamingAsync(run.Messages, session, run.Options, cancellationToken); + await foreach (string frame in OpenAIResponses.WriteResponseStreamAsync(updates, responseId, responseId, cancellationToken).ConfigureAwait(false)) + { + await http.Response.WriteAsync(frame, cancellationToken).ConfigureAwait(false); + await http.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + // Persist the post-run session under the new response id so the next turn can continue from it. + await state.SaveSessionAsync(responseId, session, cancellationToken).ConfigureAwait(false); + return Results.Empty; + } + + AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options, cancellationToken).ConfigureAwait(false); + await state.SaveSessionAsync(responseId, session, cancellationToken).ConfigureAwait(false); + return Results.Json(OpenAIResponses.WriteResponse(result, responseId, responseId)); +}); + +app.Run(); + +// Application-owned trust decision. Replace with real authentication + authorization: verify the caller, +// then authorize/bind the candidate id to the authenticated principal before returning it. +static string? Authorize(HttpContext http, string? candidateSessionId) => candidateSessionId; diff --git a/dotnet/samples/04-hosting/HostingResponsesAgent/README.md b/dotnet/samples/04-hosting/HostingResponsesAgent/README.md new file mode 100644 index 00000000000..87d56d82ec2 --- /dev/null +++ b/dotnet/samples/04-hosting/HostingResponsesAgent/README.md @@ -0,0 +1,44 @@ +# Hosting Responses Agent (app-owned routing) + +Shows how an application can own its own ASP.NET Core route and expose an `AIAgent` over the OpenAI +Responses protocol by calling the Agent Framework `OpenAIResponses` conversion helpers, instead of using +the batteries-included `MapOpenAIResponses` server. + +The application owns routing, authentication, and session storage. The framework provides only the +protocol conversion: + +- `OpenAIResponses.GetSessionId(body)` extracts the untrusted continuation-id candidate. +- `OpenAIResponses.ToAgentRunRequest(body)` parses the request into messages + run options. +- `OpenAIResponses.WriteResponse(...)` / `WriteResponseStreamAsync(...)` render the agent output back to + the Responses wire shape (non-streaming JSON and SSE). + +Session continuity uses `HostedAgentState` over an in-memory `AgentSessionStore`. + +## Run + +```bash +export AZURE_OPENAI_ENDPOINT="https://.openai.azure.com" +export AZURE_OPENAI_DEPLOYMENT="gpt-4o-mini" # optional, defaults to gpt-4o-mini +dotnet run +``` + +Call the endpoint (non-streaming): + +```bash +curl -s http://localhost:5000/responses -H "content-type: application/json" \ + -d '{ "input": "Write a haiku about the sea." }' +``` + +Streaming (SSE): + +```bash +curl -N http://localhost:5000/responses -H "content-type: application/json" \ + -d '{ "input": "Write a haiku about the sea.", "stream": true }' +``` + +## Security note + +`GetSessionId(...)` returns an untrusted candidate key. This sample'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`. diff --git a/dotnet/samples/04-hosting/HostingResponsesWorkflow/HostingResponsesWorkflow.csproj b/dotnet/samples/04-hosting/HostingResponsesWorkflow/HostingResponsesWorkflow.csproj new file mode 100644 index 00000000000..5c4122f4ab6 --- /dev/null +++ b/dotnet/samples/04-hosting/HostingResponsesWorkflow/HostingResponsesWorkflow.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + HostingResponsesWorkflow + HostingResponsesWorkflow + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs b/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs new file mode 100644 index 00000000000..560db79d9b2 --- /dev/null +++ b/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how an application can own its own ASP.NET Core route and expose a workflow over the +// OpenAI Responses protocol. It uses the OpenAIResponses conversion helpers for the wire protocol and +// HostedWorkflowState for per-session checkpoint resume. The application keeps control of routing, auth, +// and checkpoint storage. + +using System.Text; +using System.Text.Json; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Hosting.OpenAI; +using Microsoft.Agents.AI.Workflows; +using OpenAI.Chat; + +var builder = WebApplication.CreateBuilder(args); + +// Configuration via environment variables (never hardcode secrets). +string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") ?? "gpt-4o-mini"; + +var chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()).GetChatClient(deployment); +AIAgent writer = chatClient.AsAIAgent(instructions: "Write a concise first draft for the user's request.", name: "Writer"); +AIAgent reviewer = chatClient.AsAIAgent(instructions: "Improve the draft and produce the final answer.", name: "Reviewer"); + +Workflow workflow = AgentWorkflowBuilder.BuildSequential(workflowName: "WriteAndReview", agents: [writer, reviewer]); + +// Optional shared execution state: pairs the workflow with an in-memory CheckpointManager and a per-session +// sessionId -> CheckpointInfo head cursor so a session can resume from its last checkpoint. +var state = new HostedWorkflowState(workflow); + +var app = builder.Build(); + +// The application owns this route. +app.MapPost("/responses", async (HttpContext http, CancellationToken cancellationToken) => +{ + using JsonDocument doc = await JsonDocument.ParseAsync(http.Request.Body, cancellationToken: cancellationToken).ConfigureAwait(false); + JsonElement body = doc.RootElement; + + // The candidate continuation id is untrusted. A real app authenticates the caller and authorizes/binds + // this key before using it as the workflow's checkpoint session id. This sample falls back to a fresh id. + string sessionId = OpenAIResponses.GetSessionId(body) ?? OpenAIResponses.CreateResponseId(); + + OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); + + // Runs the workflow forward on the first call for this session, or resumes from the session's last + // checkpoint thereafter, then records the new head checkpoint for the session. + HostedWorkflowRunResult result = await state.RunOrResumeAsync(sessionId, run.Messages.ToList(), cancellationToken).ConfigureAwait(false); + + // Real applications extract the workflow's output from the emitted events per their workflow's design. + // For illustration this sample summarizes the run and echoes the recorded checkpoint id. + var summary = new StringBuilder() + .Append(result.Events.Count) + .Append(" workflow event(s) processed."); + if (result.Checkpoint is not null) + { + summary.Append(" Checkpoint: ").Append(result.Checkpoint.CheckpointId).Append('.'); + } + + var response = new AgentResponse(new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.Assistant, summary.ToString())); + return Results.Json(OpenAIResponses.WriteResponse(response, OpenAIResponses.CreateResponseId(), sessionId)); +}); + +app.Run(); diff --git a/dotnet/samples/04-hosting/HostingResponsesWorkflow/README.md b/dotnet/samples/04-hosting/HostingResponsesWorkflow/README.md new file mode 100644 index 00000000000..4dbe69a8672 --- /dev/null +++ b/dotnet/samples/04-hosting/HostingResponsesWorkflow/README.md @@ -0,0 +1,45 @@ +# Hosting Responses Workflow (app-owned routing + checkpoint resume) + +Shows how an application can own its own ASP.NET Core route and expose a workflow over the OpenAI +Responses protocol, using the `OpenAIResponses` conversion helpers for the wire protocol and +`HostedWorkflowState` for per-session checkpoint resume. + +The application owns routing, authentication, and checkpoint storage. `HostedWorkflowState` pairs the +workflow with a `CheckpointManager` (in-memory by default) and a per-session `sessionId -> CheckpointInfo` +head cursor, so `RunOrResumeAsync(sessionId, input)` runs the workflow forward on the first call for a +session and resumes from the session's last checkpoint thereafter. + +> The .NET workflow checkpoint store is already keyed by session id, but `CheckpointInfo` carries no +> ordering, which is why the holder remembers the head checkpoint per session. + +## Run + +```bash +export AZURE_OPENAI_ENDPOINT="https://.openai.azure.com" +export AZURE_OPENAI_DEPLOYMENT="gpt-4o-mini" # optional, defaults to gpt-4o-mini +dotnet run +``` + +Call the endpoint: + +```bash +curl -s http://localhost:5000/responses -H "content-type: application/json" \ + -d '{ "input": "Draft a short product announcement." }' +``` + +The response JSON includes the recorded checkpoint id in its summary text. Send a follow-up request with +the same `previous_response_id` (or `conversation`) to resume from that checkpoint. + +## Notes + +- Real applications extract the workflow's output from the emitted `WorkflowEvent`s per their workflow's + design; this sample summarizes the run for illustration. +- The default in-memory cursor does not survive process restarts. Durable or multi-replica hosts should + supply a durable `CheckpointManager` and record `HostedWorkflowRunResult.Checkpoint` in their own + durable cursor. + +## Security note + +`GetSessionId(...)` returns an untrusted candidate key. Authenticate the caller and authorize/bind the id +before using it as the workflow's checkpoint session id. The checkpoint boundary must be at least as +specific as the authorized session boundary. diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponses.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponses.cs new file mode 100644 index 00000000000..a9619b464ee --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponses.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.OpenAI.Responses; +using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI; + +/// +/// Side-effect-free helpers that convert between the OpenAI Responses wire protocol and Agent Framework +/// run values, for applications that own their own HTTP route, authentication, middleware, and storage. +/// +/// +/// +/// These helpers are the app-owned-routing counterpart to MapOpenAIResponses. +/// MapOpenAIResponses owns routing and storage; these helpers let an application own those concerns +/// and reuse only the protocol conversion. Both share the same internal conversion logic. +/// +/// +/// Trust boundary. returns an untrusted candidate +/// continuation key. The application must authenticate the caller and authorize/bind the id to the +/// authenticated principal before using it as a session or checkpoint key. The helpers never perform I/O. +/// +/// +public static class OpenAIResponses +{ + /// + /// Converts an OpenAI Responses request body into Agent Framework run values (messages and options). + /// + /// The OpenAI Responses-shaped request body. + /// + /// Optional options controlling how request settings are mapped onto the run. By default no request + /// setting is mapped onto the run. + /// + /// The parsed messages and mapped run options. + /// The body could not be parsed as an OpenAI Responses request. + /// A request setting is not supported by the configured mapping. + public static OpenAIResponsesRunRequest ToAgentRunRequest(JsonElement body, OpenAIResponsesMapOptions? mapOptions = null) + { + CreateResponse request = body.Deserialize(OpenAIHostingJsonContext.Default.CreateResponse) + ?? throw new ArgumentException("The request body could not be parsed as an OpenAI Responses request.", nameof(body)); + + AgentRunOptions? options = (mapOptions ?? new OpenAIResponsesMapOptions()).RunOptionsFactory(request.ToRequestInfo()); + + var messages = new List(); + foreach (InputMessage inputMessage in request.Input.GetInputMessages()) + { + messages.Add(inputMessage.ToChatMessage()); + } + + return new OpenAIResponsesRunRequest(messages, options); + } + + /// + /// Converts a final into an OpenAI Responses-shaped payload. + /// + /// The agent response to render. + /// The id to assign to the rendered response (see ). + /// + /// The optional continuation/session id to surface as the response's conversation id. + /// + /// An OpenAI Responses-shaped . + public static JsonElement WriteResponse(AgentResponse response, string responseId, string? sessionId = null) + { + ArgumentNullException.ThrowIfNull(response); + ArgumentException.ThrowIfNullOrEmpty(responseId); + + AgentInvocationContext context = CreateContext(responseId, sessionId); + Response wire = response.ToResponse(EmptyRequest(), context); + return JsonSerializer.SerializeToElement(wire, OpenAIHostingJsonContext.Default.Response); + } + + /// + /// Converts a stream of into OpenAI Responses Server-Sent-Event frames. + /// + /// The agent streaming updates. + /// The id to assign to the rendered response (see ). + /// The optional continuation/session id to surface as the response's conversation id. + /// The to monitor for cancellation requests. + /// An async sequence of SSE frame strings, each terminated by a blank line. + public static async IAsyncEnumerable WriteResponseStreamAsync( + IAsyncEnumerable updates, + string responseId, + string? sessionId = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(updates); + ArgumentException.ThrowIfNullOrEmpty(responseId); + + AgentInvocationContext context = CreateContext(responseId, sessionId); + await foreach (StreamingResponseEvent streamingEvent in updates + .ToStreamingResponseAsync(EmptyRequest(), context, cancellationToken) + .ConfigureAwait(false)) + { + string json = JsonSerializer.Serialize(streamingEvent, OpenAIHostingJsonContext.Default.StreamingResponseEvent); + yield return $"event: {streamingEvent.Type}\ndata: {json}\n\n"; + } + } + + /// + /// Extracts the continuation/session id candidate from an OpenAI Responses request body. + /// + /// The OpenAI Responses-shaped request body. + /// + /// The previous_response_id or conversation id when present; otherwise . + /// + /// + /// This is kept separate from so the + /// trust boundary stays visible: using a request-derived key is an explicit application decision. The returned + /// value is an untrusted candidate key until the application has authorized it for the caller. + /// + public static string? GetSessionId(JsonElement body) + { + CreateResponse? request = body.Deserialize(OpenAIHostingJsonContext.Default.CreateResponse); + return request?.PreviousResponseId ?? request?.Conversation?.Id; + } + + /// + /// Creates a new OpenAI Responses-shaped response id (a resp_* value). + /// + /// A new response id. + public static string CreateResponseId() => IdGenerator.NewId("resp"); + + private static AgentInvocationContext CreateContext(string responseId, string? sessionId) + => new(new IdGenerator(responseId, sessionId)); + + // The rendering converters never read the request input; a minimal request lets the facade render + // a response without requiring the caller to supply the originating request object. + private static CreateResponse EmptyRequest() => new() { Input = ResponseInput.FromText(string.Empty) }; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesRunRequest.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesRunRequest.cs new file mode 100644 index 00000000000..8a75fd45044 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesRunRequest.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI; + +/// +/// The result of converting an OpenAI Responses request body into Agent Framework run values via +/// . +/// +/// +/// This type carries the values an application passes to +/// (or the streaming equivalent) when it owns its own hosting route. It does not run the agent; the +/// application remains in control of when and how the run happens. +/// +public sealed class OpenAIResponsesRunRequest +{ + internal OpenAIResponsesRunRequest(IList messages, AgentRunOptions? options) + { + this.Messages = messages; + this.Options = options; + } + + /// + /// Gets the chat messages parsed from the request body, ready to pass to an run. + /// + public IList Messages { get; } + + /// + /// Gets the run options mapped from the request, or when no request setting is + /// mapped onto the run. The mapping is controlled by ; + /// by default no request setting is mapped. + /// + public AgentRunOptions? Options { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs index 7c0539fe511..4fe31164def 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs @@ -76,6 +76,24 @@ public abstract ValueTask GetSessionAsync( string conversationId, CancellationToken cancellationToken = default); + /// + /// Deletes a stored agent session, if present. + /// + /// The agent that owns this session. + /// The unique identifier for the conversation/session to delete. + /// The to monitor for cancellation requests. + /// A task that represents the asynchronous delete operation. + /// + /// The default implementation throws . Stores that support removal + /// override this method. Deleting a missing session is a no-op for stores that override it. + /// + /// The store does not support deletion. + public virtual ValueTask DeleteSessionAsync( + AIAgent agent, + string conversationId, + CancellationToken cancellationToken = default) + => throw new NotSupportedException($"{this.GetType().Name} does not support session deletion."); + /// Asks the for an object of the specified type . /// The type of object being requested. /// An optional key that can be used to help identify the target service. diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs index e80d3b907a3..faa4e9bd1cf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs @@ -60,6 +60,10 @@ public override ValueTask GetSessionAsync(AIAgent agent, string co public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) => this.InnerStore.SaveSessionAsync(agent, conversationId, session, cancellationToken); + /// + public override ValueTask DeleteSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + => this.InnerStore.DeleteSessionAsync(agent, conversationId, cancellationToken); + /// /// /// This implementation first checks if this instance satisfies the service request. diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs new file mode 100644 index 00000000000..b8aec87f2ae --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Optional shared execution state for applications that own their own hosting route and want to reuse +/// Agent Framework session continuity. Pairs a single target with an +/// . +/// +/// +/// +/// This holder exists because only an object that has both the resolved agent target and the session +/// store can offer a target-aware get-or-create. It does not own routing, authentication, middleware, or +/// storage policy; those remain with the application. It does not replace , +/// which already provides serialization and per-principal isolation. +/// +/// +/// Trust boundary. The sessionId values passed to these methods are +/// application-selected partition keys. When a key originates from the wire (for example via +/// OpenAIResponses.GetSessionId(...)), the application must authenticate the caller and authorize +/// the key before using it here. For multi-user hosts, scope the underlying store per principal with +/// . +/// +/// +public sealed class HostedAgentState +{ + private readonly AgentSessionStore _sessionStore; + private readonly ConcurrentDictionary? _sessionLocks; + + /// + /// Initializes a new instance of the class. + /// + /// The agent target used by route code. + /// + /// The session store to use. Defaults to a fresh when not provided. + /// + /// + /// When , serializes access per session id. Defaults + /// to . + /// + /// is . + public HostedAgentState(AIAgent agent, AgentSessionStore? sessionStore = null, bool enableSessionLocking = false) + { + ArgumentNullException.ThrowIfNull(agent); + + this.Agent = agent; + this._sessionStore = sessionStore ?? new InMemoryAgentSessionStore(); + this._sessionLocks = enableSessionLocking ? new ConcurrentDictionary(StringComparer.Ordinal) : null; + } + + /// + /// Gets the agent target. + /// + public AIAgent Agent { get; } + + /// + /// Returns the stored session for , creating a new session on first use. + /// + /// The application-selected session id. + /// The to monitor for cancellation requests. + /// The resolved or newly created . + public ValueTask GetOrCreateSessionAsync(string sessionId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(sessionId); + return this._sessionStore.GetSessionAsync(this.Agent, sessionId, cancellationToken); + } + + /// + /// Persists under . Call this after the run + /// completes, including under a newly minted continuation id when the protocol mints one. + /// + /// The application-selected session id (may be a newly minted id). + /// The session to persist. + /// The to monitor for cancellation requests. + /// A task representing the asynchronous save operation. + public ValueTask SaveSessionAsync(string sessionId, AgentSession session, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(sessionId); + ArgumentNullException.ThrowIfNull(session); + return this._sessionStore.SaveSessionAsync(this.Agent, sessionId, session, cancellationToken); + } + + /// + /// Deletes the stored session for , if present. + /// + /// The application-selected session id. + /// The to monitor for cancellation requests. + /// A task representing the asynchronous delete operation. + /// The underlying store does not support deletion. + public ValueTask DeleteSessionAsync(string sessionId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(sessionId); + return this._sessionStore.DeleteSessionAsync(this.Agent, sessionId, cancellationToken); + } + + /// + /// Acquires an exclusive lock for so concurrent requests for the same + /// session serialize their get-run-save cycle. Dispose the returned value to release the lock. + /// + /// The application-selected session id. + /// The to monitor for cancellation requests. + /// An that releases the lock when disposed. + /// + /// When session locking is not enabled, this returns immediately with a no-op releaser. + /// + public async ValueTask LockSessionAsync(string sessionId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(sessionId); + + if (this._sessionLocks is null) + { + return NoopReleaser.Instance; + } + + SemaphoreSlim gate = this._sessionLocks.GetOrAdd(sessionId, static _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + return new SemaphoreReleaser(gate); + } + + private sealed class SemaphoreReleaser(SemaphoreSlim gate) : IAsyncDisposable + { + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "The semaphore is owned by the per-session dictionary and reused across callers; the releaser only releases it.")] + private SemaphoreSlim? _gate = gate; + + public ValueTask DisposeAsync() + { + Interlocked.Exchange(ref this._gate, null)?.Release(); + return default; + } + } + + private sealed class NoopReleaser : IAsyncDisposable + { + public static readonly NoopReleaser Instance = new(); + + public ValueTask DisposeAsync() => default; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowRunResult.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowRunResult.cs new file mode 100644 index 00000000000..4a71bf33005 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowRunResult.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// The result of a run or resume. +/// +public sealed class HostedWorkflowRunResult +{ + internal HostedWorkflowRunResult(string sessionId, IReadOnlyList events, Workflows.CheckpointInfo? checkpoint) + { + this.SessionId = sessionId; + this.Events = events; + this.Checkpoint = checkpoint; + } + + /// + /// Gets the application-selected session id this run was executed under. + /// + public string SessionId { get; } + + /// + /// Gets the workflow events emitted during this run. + /// + public IReadOnlyList Events { get; } + + /// + /// Gets the head checkpoint recorded for the session after this run, or when + /// checkpointing produced no checkpoint. + /// + public Workflows.CheckpointInfo? Checkpoint { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs new file mode 100644 index 00000000000..73eabcc3589 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Optional shared execution state for applications that own their own hosting route and want to expose a +/// workflow with per-session checkpoint resume. Pairs a target with a +/// and an application-scoped sessionId -> CheckpointInfo head cursor. +/// +/// +/// +/// The .NET workflow checkpoint store is already keyed by session id, but carries +/// no ordering, so this holder remembers the head checkpoint per session to resume the correct one. It does not +/// own routing, authentication, or storage policy. +/// +/// +/// The default cursor is in-memory and does not survive process restarts; durable or multi-replica hosts should +/// supply a durable and record the returned +/// in their own durable cursor. +/// +/// +/// Trust boundary. sessionId is an application-selected partition key. When it originates +/// from the wire, the application must authenticate the caller and authorize the key before using it here. The +/// checkpoint boundary must be at least as specific as the authorized session boundary. +/// +/// +public sealed class HostedWorkflowState +{ + private readonly CheckpointManager _checkpointManager; + private readonly ConcurrentDictionary _cursor = new(StringComparer.Ordinal); + + /// + /// Initializes a new instance of the class. + /// + /// The workflow target. + /// + /// The checkpoint manager to use. Defaults to when not provided. + /// + /// is . + public HostedWorkflowState(Workflow workflow, CheckpointManager? checkpointManager = null) + { + ArgumentNullException.ThrowIfNull(workflow); + + this.Workflow = workflow; + this._checkpointManager = checkpointManager ?? CheckpointManager.CreateInMemory(); + } + + /// + /// Gets the workflow target. + /// + public Workflow Workflow { get; } + + /// + /// Runs the workflow for with checkpointing, or resumes from the session's + /// recorded head checkpoint when one exists, then records the new head checkpoint for the session. + /// + /// The workflow input type. + /// The application-selected session id. + /// The input used when starting a new run (ignored when resuming). + /// The to monitor for cancellation requests. + /// The run result, including the emitted events and the recorded head checkpoint. + public async ValueTask RunOrResumeAsync(string sessionId, TInput input, CancellationToken cancellationToken = default) + where TInput : notnull + { + ArgumentException.ThrowIfNullOrEmpty(sessionId); + ArgumentNullException.ThrowIfNull(input); + + Run run = this._cursor.TryGetValue(sessionId, out CheckpointInfo? head) + ? await InProcessExecution.ResumeAsync(this.Workflow, head, this._checkpointManager, cancellationToken).ConfigureAwait(false) + : await InProcessExecution.RunAsync(this.Workflow, input, this._checkpointManager, sessionId, cancellationToken).ConfigureAwait(false); + + await using (run.ConfigureAwait(false)) + { + var events = run.OutgoingEvents.ToList(); + CheckpointInfo? checkpoint = run.LastCheckpoint; + if (checkpoint is not null) + { + this._cursor[sessionId] = checkpoint; + } + + return new HostedWorkflowRunResult(sessionId, events, checkpoint); + } + } + + /// + /// Gets the recorded head checkpoint for , if any. + /// + /// The application-selected session id. + /// When this method returns, the recorded head checkpoint, or . + /// when a checkpoint is recorded for the session; otherwise . + public bool TryGetCheckpoint(string sessionId, out CheckpointInfo? checkpoint) + { + ArgumentException.ThrowIfNullOrEmpty(sessionId); + return this._cursor.TryGetValue(sessionId, out checkpoint); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs index f379d625fd1..1ee4c46b44a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs @@ -106,4 +106,11 @@ public override async ValueTask SaveSessionAsync(AIAgent agent, string conversat string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false); await this.InnerStore.SaveSessionAsync(agent, scopedConversationId, session, cancellationToken).ConfigureAwait(false); } + + /// + public override async ValueTask DeleteSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + { + string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false); + await this.InnerStore.DeleteSessionAsync(agent, scopedConversationId, cancellationToken).ConfigureAwait(false); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs index 832c411977f..d9a8f3613d6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs @@ -63,5 +63,12 @@ public override async ValueTask GetSessionAsync(AIAgent agent, str }; } + /// + public override ValueTask DeleteSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + { + this._threads.TryRemove(GetKey(conversationId, agent.Id), out _); + return default; + } + private static string GetKey(string conversationId, string agentId) => $"{agentId}:{conversationId}"; } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs index 163c1ea867e..34cb2c998d6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs @@ -22,4 +22,10 @@ public override ValueTask GetSessionAsync(AIAgent agent, string co { return agent.CreateSessionAsync(cancellationToken); } + + /// + public override ValueTask DeleteSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + { + return new ValueTask(); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs new file mode 100644 index 00000000000..a11d52205e3 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; + +/// +/// Unit tests for the helper facade. +/// +public class OpenAIResponsesTests +{ + [Fact] + public void ToAgentRunRequest_StringInput_ProducesUserMessage() + { + // Arrange + using var doc = JsonDocument.Parse("""{ "input": "Hello there" }"""); + + // Act + var request = OpenAIResponses.ToAgentRunRequest(doc.RootElement); + + // Assert + var message = Assert.Single(request.Messages); + Assert.Equal(ChatRole.User, message.Role); + Assert.Equal("Hello there", message.Text); + Assert.Null(request.Options); + } + + [Fact] + public void GetSessionId_PreviousResponseId_IsReturned() + { + // Arrange + using var doc = JsonDocument.Parse("""{ "input": "hi", "previous_response_id": "resp_abc" }"""); + + // Act & Assert + Assert.Equal("resp_abc", OpenAIResponses.GetSessionId(doc.RootElement)); + } + + [Fact] + public void GetSessionId_ConversationId_IsReturned() + { + // Arrange + using var doc = JsonDocument.Parse("""{ "input": "hi", "conversation": "conv_xyz" }"""); + + // Act & Assert + Assert.Equal("conv_xyz", OpenAIResponses.GetSessionId(doc.RootElement)); + } + + [Fact] + public void GetSessionId_NoContinuationKey_ReturnsNull() + { + // Arrange + using var doc = JsonDocument.Parse("""{ "input": "hi" }"""); + + // Act & Assert + Assert.Null(OpenAIResponses.GetSessionId(doc.RootElement)); + } + + [Fact] + public void CreateResponseId_HasResponsePrefix() + { + // Act + string id = OpenAIResponses.CreateResponseId(); + + // Assert + Assert.StartsWith("resp_", id); + } + + [Fact] + public void WriteResponse_RendersIdAndOutputText() + { + // Arrange + var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Hello from the agent")); + const string ResponseId = "resp_test123"; + + // Act + JsonElement payload = OpenAIResponses.WriteResponse(response, ResponseId, sessionId: "conv_1"); + + // Assert + Assert.Equal(ResponseId, payload.GetProperty("id").GetString()); + Assert.Equal("conv_1", payload.GetProperty("conversation").GetProperty("id").GetString()); + Assert.Contains("Hello from the agent", payload.GetRawText()); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentStateTests.cs new file mode 100644 index 00000000000..cec96a828bd --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentStateTests.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Unit tests for the class. +/// +public class HostedAgentStateTests +{ + private readonly Mock _agentMock = new(); + private readonly Mock _storeMock = new(); + private readonly AgentSession _session = new TestAgentSession(); + + [Fact] + public void Constructor_NullAgent_Throws() => + // Act & Assert + Assert.Throws("agent", () => new HostedAgentState(null!)); + + [Fact] + public void Constructor_NullStore_UsesInMemoryStore() + { + // Act + var state = new HostedAgentState(this._agentMock.Object); + + // Assert + Assert.Same(this._agentMock.Object, state.Agent); + } + + [Fact] + public async Task GetOrCreateSessionAsync_DelegatesToStoreWithAgentAndIdAsync() + { + // Arrange + this._storeMock + .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(this._session); + var state = new HostedAgentState(this._agentMock.Object, this._storeMock.Object); + + // Act + var result = await state.GetOrCreateSessionAsync("session-1"); + + // Assert + Assert.Same(this._session, result); + this._storeMock.Verify(x => x.GetSessionAsync(this._agentMock.Object, "session-1", It.IsAny()), Times.Once); + } + + [Fact] + public async Task SaveSessionAsync_DelegatesToStoreWithAgentAndIdAsync() + { + // Arrange + this._storeMock + .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(ValueTask.CompletedTask); + var state = new HostedAgentState(this._agentMock.Object, this._storeMock.Object); + + // Act + await state.SaveSessionAsync("resp-new", this._session); + + // Assert + this._storeMock.Verify(x => x.SaveSessionAsync(this._agentMock.Object, "resp-new", this._session, It.IsAny()), Times.Once); + } + + [Fact] + public async Task DeleteSessionAsync_DelegatesToStoreWithAgentAndIdAsync() + { + // Arrange + this._storeMock + .Setup(x => x.DeleteSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(ValueTask.CompletedTask); + var state = new HostedAgentState(this._agentMock.Object, this._storeMock.Object); + + // Act + await state.DeleteSessionAsync("session-1"); + + // Assert + this._storeMock.Verify(x => x.DeleteSessionAsync(this._agentMock.Object, "session-1", It.IsAny()), Times.Once); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task GetOrCreateSessionAsync_InvalidId_ThrowsAsync(string? sessionId) + { + // Arrange + var state = new HostedAgentState(this._agentMock.Object, this._storeMock.Object); + + // Act & Assert + await Assert.ThrowsAnyAsync(() => state.GetOrCreateSessionAsync(sessionId!).AsTask()); + } + + [Fact] + public async Task LockSessionAsync_LockingDisabled_ReturnsNoopReleaserAsync() + { + // Arrange + var state = new HostedAgentState(this._agentMock.Object, this._storeMock.Object); + + // Act + var releaser = await state.LockSessionAsync("session-1"); + + // Assert (a second acquire does not block when locking is disabled) + var second = await state.LockSessionAsync("session-1"); + await releaser.DisposeAsync(); + await second.DisposeAsync(); + } + + [Fact] + public async Task LockSessionAsync_LockingEnabled_SerializesSameSessionAsync() + { + // Arrange + var state = new HostedAgentState(this._agentMock.Object, this._storeMock.Object, enableSessionLocking: true); + var first = await state.LockSessionAsync("session-1"); + + // Act: a second acquire for the same session must not complete until the first is released. + var secondTask = state.LockSessionAsync("session-1").AsTask(); + var completedBeforeRelease = secondTask.IsCompleted; + await first.DisposeAsync(); + var second = await secondTask; + await second.DisposeAsync(); + + // Assert + Assert.False(completedBeforeRelease); + } + + private sealed class TestAgentSession : AgentSession; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs new file mode 100644 index 00000000000..9e7dd28db9c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Unit tests for the class. +/// +public class HostedWorkflowStateTests +{ + [Fact] + public void Constructor_NullWorkflow_Throws() => + // Act & Assert + Assert.Throws("workflow", () => new HostedWorkflowState(null!)); + + [Fact] + public void Workflow_ReturnsProvidedWorkflow() + { + // Arrange + var workflow = CreateTestWorkflow(); + + // Act + var state = new HostedWorkflowState(workflow); + + // Assert + Assert.Same(workflow, state.Workflow); + } + + [Fact] + public void TryGetCheckpoint_UnknownSession_ReturnsFalse() + { + // Arrange + var state = new HostedWorkflowState(CreateTestWorkflow()); + + // Act + bool found = state.TryGetCheckpoint("unknown", out var checkpoint); + + // Assert + Assert.False(found); + Assert.Null(checkpoint); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task RunOrResumeAsync_InvalidSessionId_ThrowsAsync(string? sessionId) + { + // Arrange + var state = new HostedWorkflowState(CreateTestWorkflow()); + + // Act & Assert + await Assert.ThrowsAnyAsync(() => state.RunOrResumeAsync(sessionId!, "input").AsTask()); + } + + [Fact] + public async Task RunOrResumeAsync_NullInput_ThrowsAsync() + { + // Arrange + var state = new HostedWorkflowState(CreateTestWorkflow()); + + // Act & Assert + await Assert.ThrowsAsync("input", () => state.RunOrResumeAsync("s1", null!).AsTask()); + } + + private static Workflow CreateTestWorkflow() + { + var mockAgent = new Mock(); + mockAgent.Setup(a => a.Name).Returns("testAgent"); + return AgentWorkflowBuilder.BuildSequential(workflowName: "wf", agents: [mockAgent.Object]); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs new file mode 100644 index 00000000000..dff8864fb34 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Moq.Protected; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Unit tests for across the in-box stores. +/// +public class InMemoryAgentSessionStoreTests +{ + [Fact] + public async Task DeleteSessionAsync_RemovesStoredSession_SoNextGetCreatesAsync() + { + // Arrange + var stored = JsonSerializer.SerializeToElement(new { marker = "stored" }); + var restoredSession = new TestAgentSession(); + var createdSession = new TestAgentSession(); + var agent = new Mock(); + agent.Protected() + .Setup>("SerializeSessionCoreAsync", ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(new ValueTask(stored)); + agent.Protected() + .Setup>("DeserializeSessionCoreAsync", ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(new ValueTask(restoredSession)); + agent.Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .Returns(new ValueTask(createdSession)); + + var store = new InMemoryAgentSessionStore(); + + // Act & Assert + await store.SaveSessionAsync(agent.Object, "s1", new TestAgentSession()); + Assert.Same(restoredSession, await store.GetSessionAsync(agent.Object, "s1")); + + await store.DeleteSessionAsync(agent.Object, "s1"); + Assert.Same(createdSession, await store.GetSessionAsync(agent.Object, "s1")); + } + + [Fact] + public async Task DeleteSessionAsync_UnknownId_DoesNotThrowAsync() + { + // Arrange + var store = new InMemoryAgentSessionStore(); + + // Act & Assert (no exception) + await store.DeleteSessionAsync(new Mock().Object, "missing"); + } + + [Fact] + public async Task DeleteSessionAsync_NoopStore_CompletesAsync() + { + // Arrange + var store = new NoopAgentSessionStore(); + + // Act & Assert (no exception) + await store.DeleteSessionAsync(new Mock().Object, "any"); + } + + [Fact] + public async Task DeleteSessionAsync_BaseDefault_ThrowsNotSupportedAsync() + { + // Arrange + AgentSessionStore store = new ConcreteAgentSessionStore(); + + // Act & Assert + await Assert.ThrowsAsync(() => store.DeleteSessionAsync(new Mock().Object, "any").AsTask()); + } + + private sealed class TestAgentSession : AgentSession; + + private sealed class ConcreteAgentSessionStore : AgentSessionStore + { + public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) + => default; + + public override ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + => new(new TestAgentSession()); + } +} From deb64e512f80eb7be8420cb27b8c01731e394186 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:35:25 +0100 Subject: [PATCH 02/28] Fix netstandard2.0/net472 build; harden helpers and workflow checkpoint key per review --- .../003-dotnet-hosting-protocol-helpers.md | 8 +++- .../HostingResponsesWorkflow/Program.cs | 24 ++++++++++-- .../HostingResponsesWorkflow/README.md | 3 +- .../OpenAIResponses.cs | 37 +++++++++++++++++-- .../HostedAgentState.cs | 13 ++++--- .../HostedWorkflowState.cs | 9 +++-- .../OpenAIResponsesTests.cs | 21 +++++++++++ 7 files changed, 96 insertions(+), 19 deletions(-) diff --git a/docs/specs/003-dotnet-hosting-protocol-helpers.md b/docs/specs/003-dotnet-hosting-protocol-helpers.md index 975650de1fa..8689d3888a3 100644 --- a/docs/specs/003-dotnet-hosting-protocol-helpers.md +++ b/docs/specs/003-dotnet-hosting-protocol-helpers.md @@ -197,6 +197,11 @@ app.MapPost("/responses", async (HttpContext http, CancellationToken ct) => ### Workflow over Responses with checkpoint resume +Workflow checkpoint resume requires a **stable** session key across turns. `previous_response_id` changes +every turn, so it is not a valid checkpoint key; use the `conversation` id (constant for the conversation). +Because `GetSessionId(...)` prefers `previous_response_id`, a workflow route reads the conversation id +directly rather than calling `GetSessionId(...)`. + ```csharp var state = new HostedWorkflowState(workflow); // in-memory checkpoints + cursor @@ -205,7 +210,8 @@ app.MapPost("/responses", async (HttpContext http, CancellationToken ct) => using var doc = await JsonDocument.ParseAsync(http.Request.Body, cancellationToken: ct); JsonElement body = doc.RootElement; - string sessionId = Authorize(http.User, OpenAIResponses.GetSessionId(body)) + // Stable, authorized checkpoint key. GetConversationId(...) reads the conversation id (string or object). + string sessionId = Authorize(http.User, GetConversationId(body)) ?? OpenAIResponses.CreateResponseId(); var run = OpenAIResponses.ToAgentRunRequest(body); diff --git a/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs b/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs index 560db79d9b2..d8121872c99 100644 --- a/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs +++ b/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs @@ -40,9 +40,10 @@ using JsonDocument doc = await JsonDocument.ParseAsync(http.Request.Body, cancellationToken: cancellationToken).ConfigureAwait(false); JsonElement body = doc.RootElement; - // The candidate continuation id is untrusted. A real app authenticates the caller and authorizes/binds - // this key before using it as the workflow's checkpoint session id. This sample falls back to a fresh id. - string sessionId = OpenAIResponses.GetSessionId(body) ?? OpenAIResponses.CreateResponseId(); + // Workflow checkpoint resume needs a STABLE key across turns. previous_response_id changes every turn, + // so key on the conversation id (constant for the conversation). The candidate is untrusted: a real app + // authenticates the caller and authorizes/binds this key before using it. This sample falls back to a fresh id. + string sessionId = GetConversationId(body) ?? OpenAIResponses.CreateResponseId(); OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); @@ -65,3 +66,20 @@ }); app.Run(); + +// Reads the stable conversation id (string or object form) from the request body. Unlike previous_response_id, +// the conversation id is constant across turns, so it is a valid workflow checkpoint session key. +static string? GetConversationId(JsonElement body) +{ + if (!body.TryGetProperty("conversation", out JsonElement conversation)) + { + return null; + } + + return conversation.ValueKind switch + { + JsonValueKind.String => conversation.GetString(), + JsonValueKind.Object when conversation.TryGetProperty("id", out JsonElement id) => id.GetString(), + _ => null, + }; +} diff --git a/dotnet/samples/04-hosting/HostingResponsesWorkflow/README.md b/dotnet/samples/04-hosting/HostingResponsesWorkflow/README.md index 4dbe69a8672..2537cb4b52b 100644 --- a/dotnet/samples/04-hosting/HostingResponsesWorkflow/README.md +++ b/dotnet/samples/04-hosting/HostingResponsesWorkflow/README.md @@ -28,7 +28,8 @@ curl -s http://localhost:5000/responses -H "content-type: application/json" \ ``` The response JSON includes the recorded checkpoint id in its summary text. Send a follow-up request with -the same `previous_response_id` (or `conversation`) to resume from that checkpoint. +the same `conversation` id to resume from that checkpoint. Use a **stable** key: `conversation` stays +constant across turns, whereas `previous_response_id` changes every turn and is not a valid checkpoint key. ## Notes diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponses.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponses.cs index a9619b464ee..8f27b7ef227 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponses.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponses.cs @@ -43,8 +43,21 @@ public static class OpenAIResponses /// A request setting is not supported by the configured mapping. public static OpenAIResponsesRunRequest ToAgentRunRequest(JsonElement body, OpenAIResponsesMapOptions? mapOptions = null) { - CreateResponse request = body.Deserialize(OpenAIHostingJsonContext.Default.CreateResponse) - ?? throw new ArgumentException("The request body could not be parsed as an OpenAI Responses request.", nameof(body)); + CreateResponse request; + try + { + request = body.Deserialize(OpenAIHostingJsonContext.Default.CreateResponse) + ?? throw new ArgumentException("The request body could not be parsed as an OpenAI Responses request.", nameof(body)); + } + catch (JsonException ex) + { + throw new ArgumentException("The request body could not be parsed as an OpenAI Responses request.", nameof(body), ex); + } + + if (request.Input is null) + { + throw new ArgumentException("The request body is missing the required 'input' field.", nameof(body)); + } AgentRunOptions? options = (mapOptions ?? new OpenAIResponsesMapOptions()).RunOptionsFactory(request.ToRequestInfo()); @@ -108,16 +121,32 @@ public static async IAsyncEnumerable WriteResponseStreamAsync( /// /// The OpenAI Responses-shaped request body. /// - /// The previous_response_id or conversation id when present; otherwise . + /// The previous_response_id when present; otherwise the conversation id when present; + /// otherwise . Returns for a body that cannot be parsed. /// /// /// This is kept separate from so the /// trust boundary stays visible: using a request-derived key is an explicit application decision. The returned /// value is an untrusted candidate key until the application has authorized it for the caller. + /// + /// The Responses protocol treats previous_response_id and conversation as mutually exclusive; if a + /// payload sets both, this helper prefers previous_response_id (the response-chain pointer). Note that + /// previous_response_id changes each turn and is therefore not a stable partition key; prefer the + /// conversation id when a stable key is required (for example a workflow checkpoint cursor key). + /// /// public static string? GetSessionId(JsonElement body) { - CreateResponse? request = body.Deserialize(OpenAIHostingJsonContext.Default.CreateResponse); + CreateResponse? request; + try + { + request = body.Deserialize(OpenAIHostingJsonContext.Default.CreateResponse); + } + catch (JsonException) + { + return null; + } + return request?.PreviousResponseId ?? request?.Conversation?.Id; } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs index b8aec87f2ae..f7bf055117f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs @@ -4,6 +4,7 @@ using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Hosting; @@ -46,7 +47,7 @@ public sealed class HostedAgentState /// is . public HostedAgentState(AIAgent agent, AgentSessionStore? sessionStore = null, bool enableSessionLocking = false) { - ArgumentNullException.ThrowIfNull(agent); + _ = Throw.IfNull(agent); this.Agent = agent; this._sessionStore = sessionStore ?? new InMemoryAgentSessionStore(); @@ -66,7 +67,7 @@ public HostedAgentState(AIAgent agent, AgentSessionStore? sessionStore = null, b /// The resolved or newly created . public ValueTask GetOrCreateSessionAsync(string sessionId, CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrEmpty(sessionId); + _ = Throw.IfNullOrEmpty(sessionId); return this._sessionStore.GetSessionAsync(this.Agent, sessionId, cancellationToken); } @@ -80,8 +81,8 @@ public ValueTask GetOrCreateSessionAsync(string sessionId, Cancell /// A task representing the asynchronous save operation. public ValueTask SaveSessionAsync(string sessionId, AgentSession session, CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrEmpty(sessionId); - ArgumentNullException.ThrowIfNull(session); + _ = Throw.IfNullOrEmpty(sessionId); + _ = Throw.IfNull(session); return this._sessionStore.SaveSessionAsync(this.Agent, sessionId, session, cancellationToken); } @@ -94,7 +95,7 @@ public ValueTask SaveSessionAsync(string sessionId, AgentSession session, Cancel /// The underlying store does not support deletion. public ValueTask DeleteSessionAsync(string sessionId, CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrEmpty(sessionId); + _ = Throw.IfNullOrEmpty(sessionId); return this._sessionStore.DeleteSessionAsync(this.Agent, sessionId, cancellationToken); } @@ -110,7 +111,7 @@ public ValueTask DeleteSessionAsync(string sessionId, CancellationToken cancella /// public async ValueTask LockSessionAsync(string sessionId, CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrEmpty(sessionId); + _ = Throw.IfNullOrEmpty(sessionId); if (this._sessionLocks is null) { diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs index 73eabcc3589..4c5137e66dc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Hosting; @@ -46,7 +47,7 @@ public sealed class HostedWorkflowState /// is . public HostedWorkflowState(Workflow workflow, CheckpointManager? checkpointManager = null) { - ArgumentNullException.ThrowIfNull(workflow); + _ = Throw.IfNull(workflow); this.Workflow = workflow; this._checkpointManager = checkpointManager ?? CheckpointManager.CreateInMemory(); @@ -69,8 +70,8 @@ public HostedWorkflowState(Workflow workflow, CheckpointManager? checkpointManag public async ValueTask RunOrResumeAsync(string sessionId, TInput input, CancellationToken cancellationToken = default) where TInput : notnull { - ArgumentException.ThrowIfNullOrEmpty(sessionId); - ArgumentNullException.ThrowIfNull(input); + _ = Throw.IfNullOrEmpty(sessionId); + _ = Throw.IfNull(input); Run run = this._cursor.TryGetValue(sessionId, out CheckpointInfo? head) ? await InProcessExecution.ResumeAsync(this.Workflow, head, this._checkpointManager, cancellationToken).ConfigureAwait(false) @@ -97,7 +98,7 @@ public async ValueTask RunOrResumeAsync(string /// when a checkpoint is recorded for the session; otherwise . public bool TryGetCheckpoint(string sessionId, out CheckpointInfo? checkpoint) { - ArgumentException.ThrowIfNullOrEmpty(sessionId); + _ = Throw.IfNullOrEmpty(sessionId); return this._cursor.TryGetValue(sessionId, out checkpoint); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs index a11d52205e3..36885af8f2e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Text.Json; using Microsoft.Extensions.AI; @@ -56,6 +57,26 @@ public void GetSessionId_NoContinuationKey_ReturnsNull() Assert.Null(OpenAIResponses.GetSessionId(doc.RootElement)); } + [Fact] + public void ToAgentRunRequest_InvalidBody_ThrowsArgumentException() + { + // Arrange (missing the required "input" field) + using var doc = JsonDocument.Parse("""{ "model": "x" }"""); + + // Act & Assert + Assert.Throws("body", () => OpenAIResponses.ToAgentRunRequest(doc.RootElement)); + } + + [Fact] + public void GetSessionId_MalformedBody_ReturnsNull() + { + // Arrange (an array is not a valid Responses body) + using var doc = JsonDocument.Parse("""[ 1, 2, 3 ]"""); + + // Act & Assert + Assert.Null(OpenAIResponses.GetSessionId(doc.RootElement)); + } + [Fact] public void CreateResponseId_HasResponsePrefix() { From 5d92a850e1cc9aead67f639d56c65256df9d44a2 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:02:05 +0100 Subject: [PATCH 03/28] .NET: Migrate hosting Responses samples to Azure.AI.Projects and fix workflow resume Migrate HostingResponsesAgent and HostingResponsesWorkflow samples from Azure.AI.OpenAI to Azure.AI.Projects (AIProjectClient.AsAIAgent), using the FOUNDRY_PROJECT_ENDPOINT/FOUNDRY_MODEL convention. Fix HostedWorkflowState.RunOrResumeAsync: on subsequent turns, restore the session's latest checkpoint and run the workflow forward with the new turn's input (mirroring the Python hosting host's restore-then-run semantics) instead of resuming a halted run with no input, which waited on input indefinitely. Add round-trip resume tests and update ADR-0032/spec-003 wording. --- .../0032-dotnet-hosting-protocol-helpers.md | 4 +- .../003-dotnet-hosting-protocol-helpers.md | 13 +- .../HostingResponsesAgent.csproj | 3 +- .../HostingResponsesAgent/Program.cs | 19 +-- .../HostingResponsesAgent/README.md | 4 +- .../HostingResponsesWorkflow.csproj | 3 +- .../HostingResponsesWorkflow/Program.cs | 62 ++++++---- .../HostingResponsesWorkflow/README.md | 31 +++-- .../HostedWorkflowState.cs | 65 ++++++++-- .../HostedWorkflowStateTests.cs | 57 +++++++++ .../TestEchoAgent.cs | 113 ++++++++++++++++++ 11 files changed, 308 insertions(+), 66 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/TestEchoAgent.cs diff --git a/docs/decisions/0032-dotnet-hosting-protocol-helpers.md b/docs/decisions/0032-dotnet-hosting-protocol-helpers.md index fa0585732be..44a3bc88c4f 100644 --- a/docs/decisions/0032-dotnet-hosting-protocol-helpers.md +++ b/docs/decisions/0032-dotnet-hosting-protocol-helpers.md @@ -110,7 +110,9 @@ request object). - `HostedWorkflowState`: a thin holder bundling a workflow target with a `CheckpointManager` and an internal `sessionId -> CheckpointInfo` head cursor, exposing `RunOrResumeAsync`. .NET's checkpoint store is already `sessionId`-keyed (unlike Python's workflow-name keying), but `CheckpointInfo` has - no ordering, so the holder remembers the head checkpoint per session to resume. + no ordering, so the holder remembers the head checkpoint per session to resume. On subsequent turns it + restores that checkpoint and runs the workflow forward with the new turn's input (mirroring the Python + host's restore-then-run semantics), rather than continuing a halted run with no input. ### Scope diff --git a/docs/specs/003-dotnet-hosting-protocol-helpers.md b/docs/specs/003-dotnet-hosting-protocol-helpers.md index 8689d3888a3..70b2e38e3e2 100644 --- a/docs/specs/003-dotnet-hosting-protocol-helpers.md +++ b/docs/specs/003-dotnet-hosting-protocol-helpers.md @@ -119,8 +119,8 @@ public sealed class HostedWorkflowState { public HostedWorkflowState(Workflow workflow, CheckpointManager? checkpointManager = null); - // Runs forward, or resumes from the session's last checkpoint if one is recorded, - // then records the new head checkpoint for the session. + // First turn runs forward from the start; subsequent turns restore the session's latest + // checkpoint and run forward with the new turn's input, then record the new head checkpoint. public ValueTask RunOrResumeAsync( string sessionId, object input, CancellationToken ct = default); } @@ -134,8 +134,13 @@ serializes concurrent first-touch of the same id. `DeleteSessionAsync` uses the `HostedWorkflowState` defaults to `CheckpointManager.CreateInMemory()` and an in-memory `sessionId -> CheckpointInfo` cursor. Because the checkpoint store is already `sessionId`-keyed but `CheckpointInfo` carries no ordering, the holder remembers the head checkpoint per session so -`RunOrResumeAsync` can resume the correct one. Durable/multi-replica hosts supply their own -`CheckpointManager` and (later) cursor persistence. +`RunOrResumeAsync` can resume the correct one. On subsequent turns it restores that checkpoint to +rehydrate accumulated workflow state and then runs the workflow forward with the new turn's input — +mirroring the Python hosting host's restore-then-run semantics (`agent_framework_hosting`'s +`_invoke_workflow`), rather than continuing a halted run with no input (which would wait for input +indefinitely). For agent (chat-protocol) workflows the new input is accompanied by a `TurnToken` so the +turn is driven. Durable/multi-replica hosts supply their own `CheckpointManager` and (later) cursor +persistence. ## Non-goals for v1 diff --git a/dotnet/samples/04-hosting/HostingResponsesAgent/HostingResponsesAgent.csproj b/dotnet/samples/04-hosting/HostingResponsesAgent/HostingResponsesAgent.csproj index ccb53854b1f..28fc17bb746 100644 --- a/dotnet/samples/04-hosting/HostingResponsesAgent/HostingResponsesAgent.csproj +++ b/dotnet/samples/04-hosting/HostingResponsesAgent/HostingResponsesAgent.csproj @@ -9,15 +9,14 @@ - + - diff --git a/dotnet/samples/04-hosting/HostingResponsesAgent/Program.cs b/dotnet/samples/04-hosting/HostingResponsesAgent/Program.cs index 0401a230111..f70e9762998 100644 --- a/dotnet/samples/04-hosting/HostingResponsesAgent/Program.cs +++ b/dotnet/samples/04-hosting/HostingResponsesAgent/Program.cs @@ -6,23 +6,24 @@ // and session storage; the helpers provide only the protocol <-> agent conversion. using System.Text.Json; -using Azure.AI.OpenAI; +using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Hosting.OpenAI; -using OpenAI.Chat; var builder = WebApplication.CreateBuilder(args); // Configuration via environment variables (never hardcode secrets). -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") ?? "gpt-4o-mini"; - -AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) - .GetChatClient(deployment) - .AsAIAgent(instructions: "You are a helpful assistant.", name: "Assistant"); +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; + +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()) + .AsAIAgent(model: model, instructions: "You are a helpful assistant.", name: "Assistant"); // Optional shared execution state: pairs the agent with a session store (in-memory by default). var state = new HostedAgentState(agent); diff --git a/dotnet/samples/04-hosting/HostingResponsesAgent/README.md b/dotnet/samples/04-hosting/HostingResponsesAgent/README.md index 87d56d82ec2..c513c424867 100644 --- a/dotnet/samples/04-hosting/HostingResponsesAgent/README.md +++ b/dotnet/samples/04-hosting/HostingResponsesAgent/README.md @@ -17,8 +17,8 @@ Session continuity uses `HostedAgentState` over an in-memory `AgentSessionStore` ## Run ```bash -export AZURE_OPENAI_ENDPOINT="https://.openai.azure.com" -export AZURE_OPENAI_DEPLOYMENT="gpt-4o-mini" # optional, defaults to gpt-4o-mini +export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +export FOUNDRY_MODEL="gpt-5.4-mini" # optional, defaults to gpt-5.4-mini dotnet run ``` diff --git a/dotnet/samples/04-hosting/HostingResponsesWorkflow/HostingResponsesWorkflow.csproj b/dotnet/samples/04-hosting/HostingResponsesWorkflow/HostingResponsesWorkflow.csproj index 5c4122f4ab6..fc53462cf19 100644 --- a/dotnet/samples/04-hosting/HostingResponsesWorkflow/HostingResponsesWorkflow.csproj +++ b/dotnet/samples/04-hosting/HostingResponsesWorkflow/HostingResponsesWorkflow.csproj @@ -9,15 +9,14 @@ - + - diff --git a/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs b/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs index d8121872c99..d13017d5ea2 100644 --- a/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs +++ b/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs @@ -5,26 +5,28 @@ // HostedWorkflowState for per-session checkpoint resume. The application keeps control of routing, auth, // and checkpoint storage. -using System.Text; using System.Text.Json; -using Azure.AI.OpenAI; +using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Hosting.OpenAI; using Microsoft.Agents.AI.Workflows; -using OpenAI.Chat; +using Microsoft.Extensions.AI; var builder = WebApplication.CreateBuilder(args); // Configuration via environment variables (never hardcode secrets). -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") ?? "gpt-4o-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; -var chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()).GetChatClient(deployment); -AIAgent writer = chatClient.AsAIAgent(instructions: "Write a concise first draft for the user's request.", name: "Writer"); -AIAgent reviewer = chatClient.AsAIAgent(instructions: "Improve the draft and produce the final answer.", name: "Reviewer"); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +var projectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()); +AIAgent writer = projectClient.AsAIAgent(model: model, instructions: "Write a concise first draft for the user's request.", name: "Writer"); +AIAgent reviewer = projectClient.AsAIAgent(model: model, instructions: "Improve the draft and produce the final answer.", name: "Reviewer"); Workflow workflow = AgentWorkflowBuilder.BuildSequential(workflowName: "WriteAndReview", agents: [writer, reviewer]); @@ -47,26 +49,42 @@ OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); - // Runs the workflow forward on the first call for this session, or resumes from the session's last - // checkpoint thereafter, then records the new head checkpoint for the session. + // Runs the workflow forward on the first call for this session, or restores the session's latest + // checkpoint and runs forward with this turn's input thereafter, then records the new head checkpoint. HostedWorkflowRunResult result = await state.RunOrResumeAsync(sessionId, run.Messages.ToList(), cancellationToken).ConfigureAwait(false); - // Real applications extract the workflow's output from the emitted events per their workflow's design. - // For illustration this sample summarizes the run and echoes the recorded checkpoint id. - var summary = new StringBuilder() - .Append(result.Events.Count) - .Append(" workflow event(s) processed."); - if (result.Checkpoint is not null) - { - summary.Append(" Checkpoint: ").Append(result.Checkpoint.CheckpointId).Append('.'); - } - - var response = new AgentResponse(new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.Assistant, summary.ToString())); + // Render the workflow's output. Applications extract output from the emitted events per their + // workflow's design; here we surface the final assistant message the pipeline produced this turn. + AgentResponse response = BuildWorkflowResponse(result); return Results.Json(OpenAIResponses.WriteResponse(response, OpenAIResponses.CreateResponseId(), sessionId)); }); app.Run(); +// Extracts the final assistant message produced by the workflow this turn from its output events, +// falling back to a short run summary when the workflow emitted no message output. +static AgentResponse BuildWorkflowResponse(HostedWorkflowRunResult result) +{ + ChatMessage? finalMessage = null; + foreach (WorkflowEvent evt in result.Events) + { + if (evt is WorkflowOutputEvent output && output.Data is IEnumerable messages) + { + foreach (ChatMessage message in messages) + { + if (message.Role == ChatRole.Assistant && !string.IsNullOrWhiteSpace(message.Text)) + { + finalMessage = message; + } + } + } + } + + return finalMessage is not null + ? new AgentResponse(finalMessage) + : new AgentResponse(new ChatMessage(ChatRole.Assistant, $"{result.Events.Count} workflow event(s) processed.")); +} + // Reads the stable conversation id (string or object form) from the request body. Unlike previous_response_id, // the conversation id is constant across turns, so it is a valid workflow checkpoint session key. static string? GetConversationId(JsonElement body) diff --git a/dotnet/samples/04-hosting/HostingResponsesWorkflow/README.md b/dotnet/samples/04-hosting/HostingResponsesWorkflow/README.md index 2537cb4b52b..6ef8644e3d0 100644 --- a/dotnet/samples/04-hosting/HostingResponsesWorkflow/README.md +++ b/dotnet/samples/04-hosting/HostingResponsesWorkflow/README.md @@ -6,8 +6,10 @@ Responses protocol, using the `OpenAIResponses` conversion helpers for the wire The application owns routing, authentication, and checkpoint storage. `HostedWorkflowState` pairs the workflow with a `CheckpointManager` (in-memory by default) and a per-session `sessionId -> CheckpointInfo` -head cursor, so `RunOrResumeAsync(sessionId, input)` runs the workflow forward on the first call for a -session and resumes from the session's last checkpoint thereafter. +head cursor. `RunOrResumeAsync(sessionId, input)` runs the workflow forward on the first call for a +session; on later calls it restores the session's latest checkpoint to rehydrate accumulated state and +then runs the workflow forward with the **new turn's input** (for agent workflows a `TurnToken` drives +the turn). This mirrors the Python hosting host's restore-then-run semantics. > The .NET workflow checkpoint store is already keyed by session id, but `CheckpointInfo` carries no > ordering, which is why the holder remembers the head checkpoint per session. @@ -15,26 +17,33 @@ session and resumes from the session's last checkpoint thereafter. ## Run ```bash -export AZURE_OPENAI_ENDPOINT="https://.openai.azure.com" -export AZURE_OPENAI_DEPLOYMENT="gpt-4o-mini" # optional, defaults to gpt-4o-mini +export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +export FOUNDRY_MODEL="gpt-5.4-mini" # optional, defaults to gpt-5.4-mini dotnet run ``` -Call the endpoint: +First turn — start a conversation: ```bash curl -s http://localhost:5000/responses -H "content-type: application/json" \ - -d '{ "input": "Draft a short product announcement." }' + -d '{ "input": "Draft a short product announcement for a reusable coffee mug.", "conversation": "conv-1" }' ``` -The response JSON includes the recorded checkpoint id in its summary text. Send a follow-up request with -the same `conversation` id to resume from that checkpoint. Use a **stable** key: `conversation` stays -constant across turns, whereas `previous_response_id` changes every turn and is not a valid checkpoint key. +Follow-up turn — resume with new input using the **same** `conversation` id: + +```bash +curl -s http://localhost:5000/responses -H "content-type: application/json" \ + -d '{ "input": "Rewrite it as a single punchy tagline.", "conversation": "conv-1" }' +``` + +The second turn restores the first turn's checkpoint and applies the new instruction, so the answer +builds on the earlier draft. Use a **stable** key: `conversation` stays constant across turns, whereas +`previous_response_id` changes every turn and is not a valid checkpoint key. ## Notes -- Real applications extract the workflow's output from the emitted `WorkflowEvent`s per their workflow's - design; this sample summarizes the run for illustration. +- The sample renders the workflow's final assistant message from the emitted `WorkflowEvent`s; real + applications extract output per their own workflow's design. - The default in-memory cursor does not survive process restarts. Durable or multi-replica hosts should supply a durable `CheckpointManager` and record `HostedWorkflowRunResult.Checkpoint` in their own durable cursor. diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs index 4c5137e66dc..38f47677f0f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -59,37 +60,75 @@ public HostedWorkflowState(Workflow workflow, CheckpointManager? checkpointManag public Workflow Workflow { get; } /// - /// Runs the workflow for with checkpointing, or resumes from the session's - /// recorded head checkpoint when one exists, then records the new head checkpoint for the session. + /// Runs the workflow forward for with checkpointing on the first turn, or, on + /// subsequent turns, restores the session's recorded head checkpoint and then runs the workflow forward with + /// the new turn's . The new head checkpoint is recorded for the session afterwards. /// + /// + /// The resume semantics mirror the Python hosting host (agent_framework_hosting's _invoke_workflow): + /// each turn restores the latest checkpoint to rehydrate accumulated workflow state and then applies the new + /// input, rather than continuing a halted run with no input (which would leave the run waiting for input + /// indefinitely). For agent (chat-protocol) workflows the new input is accompanied by a + /// so the turn is driven, matching the fresh-run path. + /// /// The workflow input type. /// The application-selected session id. - /// The input used when starting a new run (ignored when resuming). + /// The input to run on this turn (used both when starting a new run and when resuming). /// The to monitor for cancellation requests. - /// The run result, including the emitted events and the recorded head checkpoint. + /// The run result, including the events emitted on this turn and the recorded head checkpoint. public async ValueTask RunOrResumeAsync(string sessionId, TInput input, CancellationToken cancellationToken = default) where TInput : notnull { _ = Throw.IfNullOrEmpty(sessionId); _ = Throw.IfNull(input); - Run run = this._cursor.TryGetValue(sessionId, out CheckpointInfo? head) - ? await InProcessExecution.ResumeAsync(this.Workflow, head, this._checkpointManager, cancellationToken).ConfigureAwait(false) - : await InProcessExecution.RunAsync(this.Workflow, input, this._checkpointManager, sessionId, cancellationToken).ConfigureAwait(false); + if (!this._cursor.TryGetValue(sessionId, out CheckpointInfo? head)) + { + // First turn for this session: run the workflow forward from its start executor with the input. + Run freshRun = await InProcessExecution.RunAsync(this.Workflow, input, this._checkpointManager, sessionId, cancellationToken).ConfigureAwait(false); + await using (freshRun.ConfigureAwait(false)) + { + return this.Record(sessionId, freshRun.OutgoingEvents.ToList(), freshRun.LastCheckpoint); + } + } - await using (run.ConfigureAwait(false)) + // Subsequent turn: restore the session's latest checkpoint to rehydrate accumulated workflow state, then + // run the workflow forward with the new turn's input. Agent workflows use the chat protocol, which requires + // a TurnToken to drive the turn (mirroring how the fresh-run path seeds one). + // + // The streaming resume restores state without draining to a halt first; the non-streaming resume would + // block waiting for input immediately after restore (before we can deliver the new input). + ProtocolDescriptor descriptor = await this.Workflow.DescribeProtocolAsync(cancellationToken).ConfigureAwait(false); + + StreamingRun resumed = await InProcessExecution.ResumeStreamingAsync(this.Workflow, head, this._checkpointManager, cancellationToken).ConfigureAwait(false); + await using (resumed.ConfigureAwait(false)) { - var events = run.OutgoingEvents.ToList(); - CheckpointInfo? checkpoint = run.LastCheckpoint; - if (checkpoint is not null) + await resumed.TrySendMessageAsync(input).ConfigureAwait(false); + if (descriptor.IsChatProtocol() && input is not TurnToken) + { + await resumed.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); + } + + List events = []; + await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(cancellationToken).ConfigureAwait(false)) { - this._cursor[sessionId] = checkpoint; + events.Add(evt); } - return new HostedWorkflowRunResult(sessionId, events, checkpoint); + return this.Record(sessionId, events, resumed.LastCheckpoint); } } + private HostedWorkflowRunResult Record(string sessionId, List events, CheckpointInfo? checkpoint) + { + if (checkpoint is not null) + { + this._cursor[sessionId] = checkpoint; + } + + return new HostedWorkflowRunResult(sessionId, events, checkpoint); + } + /// /// Gets the recorded head checkpoint for , if any. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs index 9e7dd28db9c..832f1f5d02b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs @@ -1,8 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; using Moq; namespace Microsoft.Agents.AI.Hosting.UnitTests; @@ -66,6 +69,60 @@ public async Task RunOrResumeAsync_NullInput_ThrowsAsync() await Assert.ThrowsAsync("input", () => state.RunOrResumeAsync("s1", null!).AsTask()); } + [Fact] + public async Task RunOrResumeAsync_FirstTurn_RunsAndRecordsCheckpointAsync() + { + // Arrange + var state = new HostedWorkflowState(CreateEchoWorkflow()); + + // Act + HostedWorkflowRunResult result = await state.RunOrResumeAsync("s1", InputMessages("hello")); + + // Assert + Assert.NotEmpty(result.Events); + Assert.NotNull(result.Checkpoint); + Assert.True(state.TryGetCheckpoint("s1", out CheckpointInfo? checkpoint)); + Assert.Same(result.Checkpoint, checkpoint); + Assert.Contains("hello", OutputText(result)); + } + + [Fact] + public async Task RunOrResumeAsync_SecondTurn_ResumesWithNewInputAndCompletesAsync() + { + // Arrange + var state = new HostedWorkflowState(CreateEchoWorkflow()); + HostedWorkflowRunResult first = await state.RunOrResumeAsync("s1", InputMessages("hello")); + CheckpointInfo? firstCheckpoint = first.Checkpoint; + + // Act: the second turn must restore the checkpoint and run forward with the NEW input. + // A regression here (resuming with no input) would hang, so guard with a timeout. + HostedWorkflowRunResult second = await state.RunOrResumeAsync("s1", InputMessages("world")) + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(30)); + + // Assert: the resumed turn processed the new input and advanced the checkpoint. + Assert.NotEmpty(second.Events); + Assert.Contains("world", OutputText(second)); + Assert.NotNull(second.Checkpoint); + Assert.NotSame(firstCheckpoint, second.Checkpoint); + Assert.True(state.TryGetCheckpoint("s1", out CheckpointInfo? head)); + Assert.Same(second.Checkpoint, head); + } + + private static List InputMessages(string text) => [new(ChatRole.User, text)]; + + private static string OutputText(HostedWorkflowRunResult result) => + string.Concat( + result.Events + .OfType() + .Select(e => e.Data) + .OfType>() + .SelectMany(messages => messages) + .Select(m => m.Text)); + + private static Workflow CreateEchoWorkflow() => + AgentWorkflowBuilder.BuildSequential(workflowName: "echo", agents: [new TestEchoAgent(name: "echo")]); + private static Workflow CreateTestWorkflow() { var mockAgent = new Mock(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/TestEchoAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/TestEchoAgent.cs new file mode 100644 index 00000000000..4f19997bcf3 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/TestEchoAgent.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Deterministic used by workflow-hosting tests: it echoes each user message back as +/// an assistant message (optionally prefixed) and supports session serialization so it can participate in +/// checkpointed workflows. +/// +internal sealed class TestEchoAgent(string? id = null, string? name = null, string? prefix = null) : AIAgent +{ + protected override string? IdCore => id; + public override string? Name => name ?? base.Name; + + public InMemoryChatHistoryProvider ChatHistoryProvider { get; } = new(); + + protected override async ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + return serializedState.Deserialize(jsonSerializerOptions) ?? await this.CreateSessionAsync(cancellationToken); + } + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + if (session is not EchoAgentSession typedSession) + { + throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(EchoAgentSession)}' can be serialized by this agent."); + } + + return new(JsonSerializer.SerializeToElement(typedSession, jsonSerializerOptions)); + } + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new EchoAgentSession()); + + private ChatMessage UpdateSession(ChatMessage message, AgentSession? session = null) + { + this.ChatHistoryProvider.GetMessages(session).Add(message); + + return message; + } + + private List EchoMessages(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null) + { + List echoed = []; + foreach (ChatMessage message in messages) + { + this.UpdateSession(message, session); + + if (message.Role == ChatRole.User && !string.IsNullOrEmpty(message.Text)) + { + echoed.Add(this.UpdateSession(new ChatMessage(ChatRole.Assistant, $"{prefix}{message.Text}") + { + AuthorName = this.Name ?? this.Id, + CreatedAt = DateTimeOffset.Now, + MessageId = Guid.NewGuid().ToString("N") + }, session)); + } + } + + return echoed; + } + + protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + AgentResponse result = + new(this.EchoMessages(messages, session, options).ToList()) + { + AgentId = this.Id, + CreatedAt = DateTimeOffset.Now, + ResponseId = Guid.NewGuid().ToString("N"), + }; + + return Task.FromResult(result); + } + + protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + string responseId = Guid.NewGuid().ToString("N"); + + foreach (ChatMessage message in this.EchoMessages(messages, session, options).ToList()) + { + yield return + new(message.Role, message.Contents) + { + AgentId = this.Id, + AuthorName = message.AuthorName, + ResponseId = responseId, + MessageId = message.MessageId, + CreatedAt = message.CreatedAt + }; + } + + await Task.CompletedTask; + } + + private sealed class EchoAgentSession : AgentSession + { + internal EchoAgentSession() { } + + [JsonConstructor] + internal EchoAgentSession(AgentSessionStateBag stateBag) : base(stateBag) { } + } +} From b9c67173944135e53f712be01c6dc06cd6685730 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:10:04 +0100 Subject: [PATCH 04/28] .NET: Fix HostedWorkflowState resume hang on unserviced external requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On resume, HostedWorkflowState.RunOrResumeAsync drained the workflow with the blocking WatchStreamAsync overload, so a workflow that halts at an unserviced RequestInfoEvent (human-in-the-loop / approval) blocked forever — asymmetric with the first-turn RunAsync path, which returns at the same halt. Break the drain when a superstep completes with HasPendingRequests, restoring symmetry with turn 1. Add a HITL approval-gate workflow and a resume-does-not-block test. --- .../HostedWorkflowState.cs | 9 ++++ .../ApprovalGateWorkflow.cs | 52 +++++++++++++++++++ .../HostedWorkflowStateTests.cs | 24 +++++++++ 3 files changed, 85 insertions(+) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ApprovalGateWorkflow.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs index 38f47677f0f..8b1e72fea8e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs @@ -113,6 +113,15 @@ public async ValueTask RunOrResumeAsync(string await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(cancellationToken).ConfigureAwait(false)) { events.Add(evt); + + // Stop draining when the resumed workflow halts awaiting an external response. The public + // stream blocks indefinitely on an unserviced pending request, whereas the first-turn + // RunAsync path returns at this same halt (Run.RunToNextHaltAsync uses non-blocking drain). + // SuperStepCompletedEvent marks the end of the pausing superstep and carries the flag. + if (evt is SuperStepCompletedEvent { CompletionInfo.HasPendingRequests: true }) + { + break; + } } return this.Record(sessionId, events, resumed.LastCheckpoint); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ApprovalGateWorkflow.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ApprovalGateWorkflow.cs new file mode 100644 index 00000000000..9348bd3d18c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ApprovalGateWorkflow.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Builds a minimal human-in-the-loop workflow whose start executor forwards its input to a +/// , so the workflow emits a and halts awaiting an +/// external response. Used to verify that resuming such a workflow does not block indefinitely. +/// +internal static class ApprovalGateWorkflow +{ + internal const string RequestPortId = "approval"; + + internal static Workflow Build() + { + var gate = new ApprovalGateExecutor("gate", RequestPortId); + RequestPort port = RequestPort.Create(RequestPortId); + var finalize = new FinalizeExecutor("finalize"); + + return new WorkflowBuilder(gate) + .AddEdge(gate, port) + .AddEdge(port, finalize) + .WithOutputFrom(finalize) + .Build(); + } + + internal sealed record ApprovalRequest(string Prompt); + + private sealed class ApprovalGateExecutor(string id, string requestPortId) : Executor(id) + { + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) + => protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(this.HandleAsync)) + .SendsMessage(); + + private ValueTask HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken = default) + => context.SendMessageAsync(new ApprovalRequest(input), requestPortId, cancellationToken); + } + + private sealed class FinalizeExecutor(string id) : Executor(id) + { + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) + => protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(this.HandleAsync)) + .YieldsOutput(); + + private ValueTask HandleAsync(string approval, IWorkflowContext context, CancellationToken cancellationToken = default) + => context.YieldOutputAsync($"approved:{approval}", cancellationToken); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs index 832f1f5d02b..4d76cf815b7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs @@ -109,6 +109,30 @@ public async Task RunOrResumeAsync_SecondTurn_ResumesWithNewInputAndCompletesAsy Assert.Same(second.Checkpoint, head); } + [Fact] + public async Task RunOrResumeAsync_ResumeWithPendingRequest_DoesNotBlockAsync() + { + // Arrange: a human-in-the-loop workflow whose start executor forwards its input to a request port, + // so the workflow emits a RequestInfoEvent and halts awaiting an external response. + var state = new HostedWorkflowState(ApprovalGateWorkflow.Build()); + + // First turn halts at the pending request (the non-blocking baseline). + HostedWorkflowRunResult first = await state.RunOrResumeAsync("s1", "approve deploy") + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(30)); + Assert.Contains(first.Events, e => e is RequestInfoEvent); + Assert.NotNull(first.Checkpoint); + + // Act: resuming a workflow that halts at a pending request must also return instead of blocking + // forever. A regression (blocking drain) hangs here, so guard with a timeout. + HostedWorkflowRunResult second = await state.RunOrResumeAsync("s1", "approve deploy again") + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(30)); + + // Assert: the resumed turn surfaced the pending request and returned. + Assert.Contains(second.Events, e => e is RequestInfoEvent); + } + private static List InputMessages(string text) => [new(ChatRole.User, text)]; private static string OutputText(HostedWorkflowRunResult result) => From 4883b73ddfc14f7ecea1eed022312cbd8e2597d9 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:14:29 +0100 Subject: [PATCH 05/28] .NET: Warn when a HostedWorkflowState resume makes no progress Add an optional ILoggerFactory to HostedWorkflowState and log a warning when a resumed turn produces no events, mirroring the Python host's zero-event restore warning (a stale checkpoint or an input that does not match the workflow's expected type leaves session state unprogressed). Add a non-chat string workflow helper, a capturing logger, and a red/green test. --- .../HostedWorkflowState.cs | 20 +++++++++- .../CapturingLoggerFactory.cs | 38 +++++++++++++++++++ .../HostedWorkflowStateTests.cs | 20 ++++++++++ .../StringEchoWorkflow.cs | 33 ++++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/CapturingLoggerFactory.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/StringEchoWorkflow.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs index 8b1e72fea8e..2ed666714df 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs @@ -7,6 +7,8 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Hosting; @@ -36,6 +38,7 @@ namespace Microsoft.Agents.AI.Hosting; public sealed class HostedWorkflowState { private readonly CheckpointManager _checkpointManager; + private readonly ILogger _logger; private readonly ConcurrentDictionary _cursor = new(StringComparer.Ordinal); /// @@ -45,13 +48,18 @@ public sealed class HostedWorkflowState /// /// The checkpoint manager to use. Defaults to when not provided. /// + /// + /// The logger factory used to report resume diagnostics (for example, a resume turn that made no progress). + /// Defaults to when not provided. + /// /// is . - public HostedWorkflowState(Workflow workflow, CheckpointManager? checkpointManager = null) + public HostedWorkflowState(Workflow workflow, CheckpointManager? checkpointManager = null, ILoggerFactory? loggerFactory = null) { _ = Throw.IfNull(workflow); this.Workflow = workflow; this._checkpointManager = checkpointManager ?? CheckpointManager.CreateInMemory(); + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(typeof(HostedWorkflowState)); } /// @@ -124,6 +132,16 @@ public async ValueTask RunOrResumeAsync(string } } + if (events.Count == 0) + { + // The resumed turn drove no work. This mirrors the Python host's zero-event restore warning: + // the checkpoint may be stale or the input may not match the workflow's expected type, so the + // session's state may not have progressed. + this._logger.LogWarning( + "Resuming workflow session '{SessionId}' produced no events; the checkpoint may be stale or the input may not match the workflow's expected input type. Session state may not have progressed.", + sessionId); + } + return this.Record(sessionId, events, resumed.LastCheckpoint); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/CapturingLoggerFactory.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/CapturingLoggerFactory.cs new file mode 100644 index 00000000000..1e7b306bd1d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/CapturingLoggerFactory.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// A minimal that captures every log entry it produces, for asserting on +/// diagnostics emitted by the system under test. +/// +internal sealed class CapturingLoggerFactory : ILoggerFactory +{ + public List<(LogLevel Level, string Message)> Entries { get; } = []; + + public ILogger CreateLogger(string categoryName) => new CapturingLogger(this.Entries); + + public void AddProvider(ILoggerProvider provider) + { + // No-op: this factory always produces capturing loggers. + } + + public void Dispose() + { + // No-op. + } + + private sealed class CapturingLogger(List<(LogLevel Level, string Message)> entries) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + => entries.Add((logLevel, formatter(state, exception))); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs index 4d76cf815b7..246271c0172 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; using Moq; namespace Microsoft.Agents.AI.Hosting.UnitTests; @@ -133,6 +134,25 @@ public async Task RunOrResumeAsync_ResumeWithPendingRequest_DoesNotBlockAsync() Assert.Contains(second.Events, e => e is RequestInfoEvent); } + [Fact] + public async Task RunOrResumeAsync_ResumeMakesNoProgress_LogsWarningAsync() + { + // Arrange: a non-chat-protocol workflow that completes on the first turn. + var loggerFactory = new CapturingLoggerFactory(); + var state = new HostedWorkflowState(StringEchoWorkflow.Build(), loggerFactory: loggerFactory); + HostedWorkflowRunResult first = await state.RunOrResumeAsync("s1", "hello"); + Assert.NotEmpty(first.Events); + Assert.NotNull(first.Checkpoint); + + // Act: resume with an input the start executor cannot handle, so the turn drives no work. + HostedWorkflowRunResult second = await state.RunOrResumeAsync("s1", 42); + + // Assert: a resume that produced no events is surfaced as a warning (possible stale checkpoint / + // mismatched input), mirroring the Python host's zero-event restore warning. + Assert.Empty(second.Events); + Assert.Contains(loggerFactory.Entries, e => e.Level == LogLevel.Warning); + } + private static List InputMessages(string text) => [new(ChatRole.User, text)]; private static string OutputText(HostedWorkflowRunResult result) => diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/StringEchoWorkflow.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/StringEchoWorkflow.cs new file mode 100644 index 00000000000..7d7436ac069 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/StringEchoWorkflow.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Builds a minimal non-chat-protocol workflow whose single executor accepts a and yields +/// it back as output. Used to exercise the resume path for workflows whose start executor does not accept +/// List<ChatMessage> (so no is sent). +/// +internal static class StringEchoWorkflow +{ + internal static Workflow Build() + { + var echo = new StringEchoExecutor("echo"); + return new WorkflowBuilder(echo) + .WithOutputFrom(echo) + .Build(); + } + + private sealed class StringEchoExecutor(string id) : Executor(id) + { + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) + => protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(this.HandleAsync)) + .YieldsOutput(); + + private ValueTask HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken = default) + => context.YieldOutputAsync($"echo:{input}", cancellationToken); + } +} From ad9185235cb63c33e7e236dd94296f7741b0a68c Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:20:05 +0100 Subject: [PATCH 06/28] .NET: Resume HostedWorkflowState from durable checkpoint on cursor miss Add CheckpointManager.GetLatestCheckpointAsync(sessionId) and have HostedWorkflowState fall back to it when its in-memory head cursor misses, so a durable CheckpointManager resumes a session across a process restart or a new holder instead of restarting from the workflow's start executor. Mirrors the Python host's per-turn get_latest read-through. Add a counting workflow that proves resume-vs-fresh via accumulated state, plus a red/green test, and update ADR-0032/spec-003 and the XML remarks. --- .../0032-dotnet-hosting-protocol-helpers.md | 4 +- .../003-dotnet-hosting-protocol-helpers.md | 7 +++- .../HostedWorkflowState.cs | 15 +++++-- .../CheckpointManager.cs | 24 ++++++++++++ .../CountingWorkflow.cs | 39 +++++++++++++++++++ .../HostedWorkflowStateTests.cs | 30 ++++++++++++++ 6 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/CountingWorkflow.cs diff --git a/docs/decisions/0032-dotnet-hosting-protocol-helpers.md b/docs/decisions/0032-dotnet-hosting-protocol-helpers.md index 44a3bc88c4f..a2a87b606e7 100644 --- a/docs/decisions/0032-dotnet-hosting-protocol-helpers.md +++ b/docs/decisions/0032-dotnet-hosting-protocol-helpers.md @@ -112,7 +112,9 @@ request object). store is already `sessionId`-keyed (unlike Python's workflow-name keying), but `CheckpointInfo` has no ordering, so the holder remembers the head checkpoint per session to resume. On subsequent turns it restores that checkpoint and runs the workflow forward with the new turn's input (mirroring the Python - host's restore-then-run semantics), rather than continuing a halted run with no input. + host's restore-then-run semantics), rather than continuing a halted run with no input. When the + in-memory cursor misses (new holder / process restart) it reads the session's latest checkpoint from the + `CheckpointManager`, so a durable manager resumes across restarts. ### Scope diff --git a/docs/specs/003-dotnet-hosting-protocol-helpers.md b/docs/specs/003-dotnet-hosting-protocol-helpers.md index 70b2e38e3e2..44c3d7fc416 100644 --- a/docs/specs/003-dotnet-hosting-protocol-helpers.md +++ b/docs/specs/003-dotnet-hosting-protocol-helpers.md @@ -139,8 +139,11 @@ rehydrate accumulated workflow state and then runs the workflow forward with the mirroring the Python hosting host's restore-then-run semantics (`agent_framework_hosting`'s `_invoke_workflow`), rather than continuing a halted run with no input (which would wait for input indefinitely). For agent (chat-protocol) workflows the new input is accompanied by a `TurnToken` so the -turn is driven. Durable/multi-replica hosts supply their own `CheckpointManager` and (later) cursor -persistence. +turn is driven. When the in-memory cursor misses (a new holder or a process restart), the holder falls +back to `CheckpointManager.GetLatestCheckpointAsync(sessionId)`, so a durable `CheckpointManager` resumes +correctly across restarts (the default in-memory manager does not persist, so a restart starts fresh). A +resume that produces no events is logged as a warning (possible stale checkpoint or mismatched input), +mirroring the Python host's zero-event restore warning. ## Non-goals for v1 diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs index 2ed666714df..4a8dfed99a4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs @@ -25,9 +25,10 @@ namespace Microsoft.Agents.AI.Hosting; /// own routing, authentication, or storage policy. /// /// -/// The default cursor is in-memory and does not survive process restarts; durable or multi-replica hosts should -/// supply a durable and record the returned -/// in their own durable cursor. +/// The in-memory head cursor accelerates the common case, but when it misses (for example a new holder or a +/// process restart) the holder falls back to . A durable +/// therefore resumes correctly across restarts; the default in-memory manager does +/// not persist, so with it a restart starts the session fresh. /// /// /// Trust boundary. sessionId is an application-selected partition key. When it originates @@ -91,6 +92,14 @@ public async ValueTask RunOrResumeAsync(string _ = Throw.IfNull(input); if (!this._cursor.TryGetValue(sessionId, out CheckpointInfo? head)) + { + // The in-memory cursor is empty for this session. Fall back to the checkpoint manager so a durable + // manager still resumes after the cursor is lost (for example a process restart or a new holder over + // the same store), mirroring the Python host's per-turn get_latest read-through. + head = await this._checkpointManager.GetLatestCheckpointAsync(sessionId, cancellationToken).ConfigureAwait(false); + } + + if (head is null) { // First turn for this session: run the workflow forward from its start executor with the input. Run freshRun = await InProcessExecution.RunAsync(this.Workflow, input, this._checkpointManager, sessionId, cancellationToken).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/CheckpointManager.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/CheckpointManager.cs index 2b5bb5b0347..5ff7b84437d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/CheckpointManager.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/CheckpointManager.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Checkpointing; @@ -58,4 +59,27 @@ ValueTask ICheckpointManager.LookupCheckpointAsync(string sessionId, ValueTask> ICheckpointManager.RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent) => this._impl.RetrieveIndexAsync(sessionId, withParent); + + /// + /// Retrieves the most recently committed checkpoint for the specified session, or + /// when the session has no checkpoints. + /// + /// The session identifier whose latest checkpoint should be retrieved. + /// The to monitor for cancellation requests. + /// + /// The latest for , or when no + /// checkpoint has been committed for that session. + /// + public async ValueTask GetLatestCheckpointAsync(string sessionId, CancellationToken cancellationToken = default) + { + IEnumerable index = await this._impl.RetrieveIndexAsync(sessionId, withParent: null).ConfigureAwait(false); + + CheckpointInfo? latest = null; + foreach (CheckpointInfo info in index) + { + latest = info; + } + + return latest; + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/CountingWorkflow.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/CountingWorkflow.cs new file mode 100644 index 00000000000..c80b8b843e3 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/CountingWorkflow.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Builds a non-chat-protocol workflow whose single executor keeps a running count in workflow state and +/// yields count:N. Because the count is checkpointed, a genuine resume observes the accumulated value +/// (for example count:2 on the second turn), whereas a fresh run restarts at count:1. Used to +/// prove that a turn actually resumed from a prior checkpoint rather than starting over. +/// +internal static class CountingWorkflow +{ + internal static Workflow Build() + { + var counter = new CountingExecutor("counter"); + return new WorkflowBuilder(counter) + .WithOutputFrom(counter) + .Build(); + } + + private sealed class CountingExecutor(string id) : Executor(id) + { + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) + => protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(this.HandleAsync)) + .YieldsOutput(); + + private async ValueTask HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken = default) + { + int count = await context.ReadOrInitStateAsync("count", () => 0, cancellationToken).ConfigureAwait(false); + count++; + await context.QueueStateUpdateAsync("count", count, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.YieldOutputAsync($"count:{count}", cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs index 246271c0172..30b9d9f742e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs @@ -153,6 +153,29 @@ public async Task RunOrResumeAsync_ResumeMakesNoProgress_LogsWarningAsync() Assert.Contains(loggerFactory.Entries, e => e.Level == LogLevel.Warning); } + [Fact] + public async Task RunOrResumeAsync_CursorMiss_ResumesFromManagerLatestCheckpointAsync() + { + // Arrange: a shared checkpoint manager stands in for durable storage that outlives the in-memory + // cursor. The first holder runs one turn; a counting workflow records count:1 in the checkpoint. + var manager = CheckpointManager.CreateInMemory(); + var first = new HostedWorkflowState(CountingWorkflow.Build(), manager); + HostedWorkflowRunResult firstResult = await first.RunOrResumeAsync("s1", "go"); + Assert.Contains("count:1", StringOutput(firstResult)); + + // Act: a NEW holder over the SAME manager (fresh cursor, e.g. after a process restart) runs the + // session again. With durable read-through it resumes from the manager's latest checkpoint. + var second = new HostedWorkflowState(CountingWorkflow.Build(), manager); + HostedWorkflowRunResult resumed = await second.RunOrResumeAsync("s1", "go") + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(30)); + + // Assert: the count advanced to 2, proving it resumed from the prior checkpoint rather than + // restarting from scratch (which would yield count:1 again). + Assert.Contains("count:2", StringOutput(resumed)); + Assert.True(second.TryGetCheckpoint("s1", out _)); + } + private static List InputMessages(string text) => [new(ChatRole.User, text)]; private static string OutputText(HostedWorkflowRunResult result) => @@ -164,6 +187,13 @@ private static string OutputText(HostedWorkflowRunResult result) => .SelectMany(messages => messages) .Select(m => m.Text)); + private static string StringOutput(HostedWorkflowRunResult result) => + string.Concat( + result.Events + .OfType() + .Select(e => e.Data) + .OfType()); + private static Workflow CreateEchoWorkflow() => AgentWorkflowBuilder.BuildSequential(workflowName: "echo", agents: [new TestEchoAgent(name: "echo")]); From 3358668c356b8d18836517caf800a4e7551b2c6d Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:25:35 +0100 Subject: [PATCH 07/28] .NET: Serialize HostedWorkflowState turns through a workflow lock A single workflow instance backs the holder and workflow instances do not support concurrent runs (the runner throws "already owned by another runner"), so concurrent turns could fault or race the head cursor. Serialize all turns through one SemaphoreSlim (mirroring the Python host's workflow lock) and make HostedWorkflowState IDisposable to own it. Add a gated workflow and a deterministic concurrency red/green test. --- .../003-dotnet-hosting-protocol-helpers.md | 4 +- .../HostedWorkflowState.cs | 27 +++++++++++- .../GatedCountingWorkflow.cs | 42 +++++++++++++++++++ .../HostedWorkflowStateTests.cs | 32 ++++++++++++++ 4 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/GatedCountingWorkflow.cs diff --git a/docs/specs/003-dotnet-hosting-protocol-helpers.md b/docs/specs/003-dotnet-hosting-protocol-helpers.md index 44c3d7fc416..3f09e20b2e2 100644 --- a/docs/specs/003-dotnet-hosting-protocol-helpers.md +++ b/docs/specs/003-dotnet-hosting-protocol-helpers.md @@ -143,7 +143,9 @@ turn is driven. When the in-memory cursor misses (a new holder or a process rest back to `CheckpointManager.GetLatestCheckpointAsync(sessionId)`, so a durable `CheckpointManager` resumes correctly across restarts (the default in-memory manager does not persist, so a restart starts fresh). A resume that produces no events is logged as a warning (possible stale checkpoint or mismatched input), -mirroring the Python host's zero-event restore warning. +mirroring the Python host's zero-event restore warning. Because a single workflow instance backs the +holder and workflow instances do not support concurrent runs, `RunOrResumeAsync` serializes turns through +one lock (mirroring the Python host's workflow lock); `HostedWorkflowState` is therefore `IDisposable`. ## Non-goals for v1 diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs index 4a8dfed99a4..036c7d8e514 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs @@ -36,12 +36,16 @@ namespace Microsoft.Agents.AI.Hosting; /// checkpoint boundary must be at least as specific as the authorized session boundary. /// /// -public sealed class HostedWorkflowState +public sealed class HostedWorkflowState : IDisposable { private readonly CheckpointManager _checkpointManager; private readonly ILogger _logger; private readonly ConcurrentDictionary _cursor = new(StringComparer.Ordinal); + // A single workflow instance backs every session on this holder, and workflow instances do not support + // concurrent runs, so all turns are serialized through one lock (mirroring the Python host's workflow lock). + private readonly SemaphoreSlim _workflowLock = new(1, 1); + /// /// Initializes a new instance of the class. /// @@ -91,6 +95,22 @@ public async ValueTask RunOrResumeAsync(string _ = Throw.IfNullOrEmpty(sessionId); _ = Throw.IfNull(input); + // Serialize turns: the shared workflow instance cannot be run by two runners at once, and concurrent + // same-session turns would otherwise race the head cursor. + await this._workflowLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await this.RunOrResumeCoreAsync(sessionId, input, cancellationToken).ConfigureAwait(false); + } + finally + { + this._workflowLock.Release(); + } + } + + private async ValueTask RunOrResumeCoreAsync(string sessionId, TInput input, CancellationToken cancellationToken) + where TInput : notnull + { if (!this._cursor.TryGetValue(sessionId, out CheckpointInfo? head)) { // The in-memory cursor is empty for this session. Fall back to the checkpoint manager so a durable @@ -176,4 +196,9 @@ public bool TryGetCheckpoint(string sessionId, out CheckpointInfo? checkpoint) _ = Throw.IfNullOrEmpty(sessionId); return this._cursor.TryGetValue(sessionId, out checkpoint); } + + /// + /// Releases the resources used by this instance, including the workflow serialization lock. + /// + public void Dispose() => this._workflowLock.Dispose(); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/GatedCountingWorkflow.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/GatedCountingWorkflow.cs new file mode 100644 index 00000000000..2b5c8802c64 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/GatedCountingWorkflow.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Builds a non-chat-protocol workflow whose executor signals when it starts and then blocks on a +/// test-controlled gate before finishing. This lets a test hold a turn "inside" the workflow and observe +/// whether a second, concurrent turn for the same holder is allowed to run at the same time. +/// +internal static class GatedCountingWorkflow +{ + internal static Workflow Build(SemaphoreSlim entered, Task release) + { + var gated = new GatedExecutor("gated", entered, release); + return new WorkflowBuilder(gated) + .WithOutputFrom(gated) + .Build(); + } + + private sealed class GatedExecutor(string id, SemaphoreSlim entered, Task release) : Executor(id) + { + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) + => protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(this.HandleAsync)) + .YieldsOutput(); + + private async ValueTask HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken = default) + { + // Signal that this turn has started running inside the workflow, then wait for the test to release. + entered.Release(); + await release.ConfigureAwait(false); + + int count = await context.ReadOrInitStateAsync("count", () => 0, cancellationToken).ConfigureAwait(false); + count++; + await context.QueueStateUpdateAsync("count", count, cancellationToken: cancellationToken).ConfigureAwait(false); + await context.YieldOutputAsync($"count:{count}", cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs index 30b9d9f742e..817130c9c6f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; @@ -176,6 +177,37 @@ public async Task RunOrResumeAsync_CursorMiss_ResumesFromManagerLatestCheckpoint Assert.True(second.TryGetCheckpoint("s1", out _)); } + [Fact] + public async Task RunOrResumeAsync_ConcurrentSameSessionTurns_AreSerializedAsync() + { + // Arrange: a workflow that signals when a turn enters and then blocks on a gate, so the test can + // hold the first turn "inside" the workflow while it starts a second turn for the same session. + using var entered = new SemaphoreSlim(0, 2); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var state = new HostedWorkflowState(GatedCountingWorkflow.Build(entered, release.Task), CheckpointManager.CreateInMemory()); + + // Act: start the first turn and wait until it is running inside the workflow. + Task first = state.RunOrResumeAsync("s1", "go").AsTask(); + Assert.True(await entered.WaitAsync(TimeSpan.FromSeconds(10)), "the first turn should enter the workflow"); + + // Start a second same-session turn while the first is still inside the workflow. + Task second = state.RunOrResumeAsync("s1", "go").AsTask(); + + // Assert: the second turn must NOT enter the workflow while the first holds it (a shared workflow + // instance does not support concurrent runs). Without serialization it would enter concurrently. + bool secondEntered = await entered.WaitAsync(TimeSpan.FromSeconds(1)); + Assert.False(secondEntered, "the second same-session turn must wait for the first to complete"); + + // Release both turns and let them run to completion. + release.SetResult(); + HostedWorkflowRunResult[] results = await Task.WhenAll(first, second).WaitAsync(TimeSpan.FromSeconds(30)); + + // The serialized turns advanced the count from 1 to 2. + string combined = string.Concat(results.Select(StringOutput)); + Assert.Contains("count:1", combined); + Assert.Contains("count:2", combined); + } + private static List InputMessages(string text) => [new(ChatRole.User, text)]; private static string OutputText(HostedWorkflowRunResult result) => From 2e4dbb4c8742c022b3988876f4a732a47a3648df Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:26:41 +0100 Subject: [PATCH 08/28] .NET: Cover non-chat resume and multi-turn checkpoint advance Add tests for HostedWorkflowState resuming a non-chat-protocol workflow (no TurnToken) and for a third turn continuing to advance the head checkpoint, closing the coverage gaps the parity review flagged. --- .../HostedWorkflowStateTests.cs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs index 817130c9c6f..aa41cf21ea3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs @@ -208,6 +208,51 @@ public async Task RunOrResumeAsync_ConcurrentSameSessionTurns_AreSerializedAsync Assert.Contains("count:2", combined); } + [Fact] + public async Task RunOrResumeAsync_NonChatWorkflow_ResumesWithNewInputAsync() + { + // Arrange: a non-chat-protocol workflow (string start executor), so the resume path sends the input + // without a TurnToken. + var state = new HostedWorkflowState(CountingWorkflow.Build()); + HostedWorkflowRunResult first = await state.RunOrResumeAsync("s1", "go"); + Assert.Contains("count:1", StringOutput(first)); + + // Act + HostedWorkflowRunResult second = await state.RunOrResumeAsync("s1", "go") + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(30)); + + // Assert: the non-chat resume carried state and advanced the checkpoint. + Assert.Contains("count:2", StringOutput(second)); + Assert.NotNull(second.Checkpoint); + Assert.NotSame(first.Checkpoint, second.Checkpoint); + } + + [Fact] + public async Task RunOrResumeAsync_ThirdTurn_KeepsAdvancingCheckpointAsync() + { + // Arrange + var state = new HostedWorkflowState(CreateEchoWorkflow()); + + // Act: three turns on the same session. + HostedWorkflowRunResult r1 = await state.RunOrResumeAsync("s1", InputMessages("a")); + HostedWorkflowRunResult r2 = await state.RunOrResumeAsync("s1", InputMessages("b")) + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(30)); + HostedWorkflowRunResult r3 = await state.RunOrResumeAsync("s1", InputMessages("c")) + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(30)); + + // Assert: the cursor keeps advancing past the second turn, and the head reflects the latest turn. + Assert.Contains("c", OutputText(r3)); + Assert.NotNull(r1.Checkpoint); + Assert.NotNull(r3.Checkpoint); + Assert.NotSame(r1.Checkpoint, r2.Checkpoint); + Assert.NotSame(r2.Checkpoint, r3.Checkpoint); + Assert.True(state.TryGetCheckpoint("s1", out CheckpointInfo? head)); + Assert.Same(r3.Checkpoint, head); + } + private static List InputMessages(string text) => [new(ChatRole.User, text)]; private static string OutputText(HostedWorkflowRunResult result) => From 23ea2cab07496d9580137f3144001b4c8fcd0ae2 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:34:18 +0100 Subject: [PATCH 09/28] .NET: Add streaming workflow resume path and stream the workflow sample Add HostedWorkflowState.RunOrResumeStreamingAsync, which yields the turn's WorkflowEvents as they occur (fresh run or checkpoint resume) under the same serialization lock and records the head checkpoint after the stream drains, keeping the blocking and streaming workflow paths in lockstep with the Python host. Honor stream:true in the HostingResponsesWorkflow sample by projecting AgentResponseUpdateEvent updates over the Responses SSE wire. Add a streaming resume test and update the README/spec. --- .../003-dotnet-hosting-protocol-helpers.md | 5 +- .../HostingResponsesWorkflow/Program.cs | 38 ++++++- .../HostingResponsesWorkflow/README.md | 8 ++ .../HostedWorkflowState.cs | 103 ++++++++++++++++-- .../HostedWorkflowStateTests.cs | 38 +++++++ 5 files changed, 182 insertions(+), 10 deletions(-) diff --git a/docs/specs/003-dotnet-hosting-protocol-helpers.md b/docs/specs/003-dotnet-hosting-protocol-helpers.md index 3f09e20b2e2..bd42bc2185b 100644 --- a/docs/specs/003-dotnet-hosting-protocol-helpers.md +++ b/docs/specs/003-dotnet-hosting-protocol-helpers.md @@ -145,7 +145,10 @@ correctly across restarts (the default in-memory manager does not persist, so a resume that produces no events is logged as a warning (possible stale checkpoint or mismatched input), mirroring the Python host's zero-event restore warning. Because a single workflow instance backs the holder and workflow instances do not support concurrent runs, `RunOrResumeAsync` serializes turns through -one lock (mirroring the Python host's workflow lock); `HostedWorkflowState` is therefore `IDisposable`. +one lock (mirroring the Python host's workflow lock); `HostedWorkflowState` is therefore `IDisposable`. A +streaming counterpart, `RunOrResumeStreamingAsync`, yields the turn's `WorkflowEvent`s as they occur (for +example to render agent updates over the Responses SSE wire) and records the head checkpoint once the +stream is fully enumerated, keeping the blocking and streaming workflow paths in lockstep as Python does. ## Non-goals for v1 diff --git a/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs b/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs index d13017d5ea2..37dd8586f5e 100644 --- a/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs +++ b/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs @@ -48,10 +48,31 @@ string sessionId = GetConversationId(body) ?? OpenAIResponses.CreateResponseId(); OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); + var messages = run.Messages.ToList(); + + bool stream = body.TryGetProperty("stream", out JsonElement streamProp) && streamProp.ValueKind == JsonValueKind.True; + + if (stream) + { + // Stream the workflow's agent updates back over the Responses SSE wire. Runs forward on the first + // call for this session, or restores the session's latest checkpoint and runs forward with this + // turn's input thereafter, recording the new head checkpoint once the stream completes. + http.Response.ContentType = "text/event-stream"; + string streamResponseId = OpenAIResponses.CreateResponseId(); + IAsyncEnumerable updates = ExtractUpdates(state.RunOrResumeStreamingAsync(sessionId, messages, cancellationToken), cancellationToken); + + await foreach (string frame in OpenAIResponses.WriteResponseStreamAsync(updates, streamResponseId, sessionId, cancellationToken).ConfigureAwait(false)) + { + await http.Response.WriteAsync(frame, cancellationToken).ConfigureAwait(false); + await http.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + return Results.Empty; + } // Runs the workflow forward on the first call for this session, or restores the session's latest // checkpoint and runs forward with this turn's input thereafter, then records the new head checkpoint. - HostedWorkflowRunResult result = await state.RunOrResumeAsync(sessionId, run.Messages.ToList(), cancellationToken).ConfigureAwait(false); + HostedWorkflowRunResult result = await state.RunOrResumeAsync(sessionId, messages, cancellationToken).ConfigureAwait(false); // Render the workflow's output. Applications extract output from the emitted events per their // workflow's design; here we surface the final assistant message the pipeline produced this turn. @@ -61,6 +82,21 @@ app.Run(); +// Projects the workflow's per-agent streaming updates from the emitted workflow events, so the app can +// render them over the Responses SSE wire. +static async IAsyncEnumerable ExtractUpdates( + IAsyncEnumerable events, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) +{ + await foreach (WorkflowEvent evt in events.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + if (evt is AgentResponseUpdateEvent updateEvent) + { + yield return updateEvent.Update; + } + } +} + // Extracts the final assistant message produced by the workflow this turn from its output events, // falling back to a short run summary when the workflow emitted no message output. static AgentResponse BuildWorkflowResponse(HostedWorkflowRunResult result) diff --git a/dotnet/samples/04-hosting/HostingResponsesWorkflow/README.md b/dotnet/samples/04-hosting/HostingResponsesWorkflow/README.md index 6ef8644e3d0..a7031bc27b7 100644 --- a/dotnet/samples/04-hosting/HostingResponsesWorkflow/README.md +++ b/dotnet/samples/04-hosting/HostingResponsesWorkflow/README.md @@ -40,6 +40,14 @@ The second turn restores the first turn's checkpoint and applies the new instruc builds on the earlier draft. Use a **stable** key: `conversation` stays constant across turns, whereas `previous_response_id` changes every turn and is not a valid checkpoint key. +Streaming (SSE) — add `"stream": true` to stream the workflow's agent updates back over the Responses +Server-Sent-Events wire (works for both the first turn and resumed turns): + +```bash +curl -N http://localhost:5000/responses -H "content-type: application/json" \ + -d '{ "input": "Draft a short slogan for a coffee mug.", "conversation": "conv-1", "stream": true }' +``` + ## Notes - The sample renders the workflow's final assistant message from the emitted `WorkflowEvent`s; real diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs index 036c7d8e514..330a75b2e18 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs @@ -4,6 +4,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows; @@ -163,28 +164,114 @@ private async ValueTask RunOrResumeCoreAsync(st if (events.Count == 0) { - // The resumed turn drove no work. This mirrors the Python host's zero-event restore warning: - // the checkpoint may be stale or the input may not match the workflow's expected type, so the - // session's state may not have progressed. - this._logger.LogWarning( - "Resuming workflow session '{SessionId}' produced no events; the checkpoint may be stale or the input may not match the workflow's expected input type. Session state may not have progressed.", - sessionId); + this.WarnOnNoProgress(sessionId); } return this.Record(sessionId, events, resumed.LastCheckpoint); } } + /// + /// Streams the events of a run-or-resume turn as they occur, applying the same restore-then-run semantics as + /// : the first turn runs the workflow + /// forward from its start executor, and subsequent turns restore the session's latest checkpoint and run + /// forward with . The session's head checkpoint is recorded once the stream is fully + /// enumerated. + /// + /// + /// Turns are serialized through the holder's workflow lock, which is held for the lifetime of the returned + /// enumerator. The caller must enumerate the stream to completion (or dispose it) to release the lock. Because + /// the head checkpoint is recorded only after the stream completes, abandoning the enumeration early does not + /// advance the session cursor. + /// + /// The workflow input type. + /// The application-selected session id. + /// The input to run on this turn. + /// The to monitor for cancellation requests. + /// An asynchronous stream of the s emitted during this turn. + public async IAsyncEnumerable RunOrResumeStreamingAsync(string sessionId, TInput input, [EnumeratorCancellation] CancellationToken cancellationToken = default) + where TInput : notnull + { + _ = Throw.IfNullOrEmpty(sessionId); + _ = Throw.IfNull(input); + + await this._workflowLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (!this._cursor.TryGetValue(sessionId, out CheckpointInfo? head)) + { + head = await this._checkpointManager.GetLatestCheckpointAsync(sessionId, cancellationToken).ConfigureAwait(false); + } + + ProtocolDescriptor descriptor = await this.Workflow.DescribeProtocolAsync(cancellationToken).ConfigureAwait(false); + + // The fresh streaming run enqueues the input itself; the streaming resume restores state and needs the + // input delivered explicitly. Neither streaming entry point seeds a TurnToken, so drive chat-protocol + // workflows with one on both paths. + StreamingRun run = head is null + ? await InProcessExecution.RunStreamingAsync(this.Workflow, input, this._checkpointManager, sessionId, cancellationToken).ConfigureAwait(false) + : await InProcessExecution.ResumeStreamingAsync(this.Workflow, head, this._checkpointManager, cancellationToken).ConfigureAwait(false); + + await using (run.ConfigureAwait(false)) + { + if (head is not null) + { + await run.TrySendMessageAsync(input).ConfigureAwait(false); + } + + if (descriptor.IsChatProtocol() && input is not TurnToken) + { + await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); + } + + int eventCount = 0; + await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false)) + { + eventCount++; + yield return evt; + + if (evt is SuperStepCompletedEvent { CompletionInfo.HasPendingRequests: true }) + { + break; + } + } + + if (eventCount == 0 && head is not null) + { + this.WarnOnNoProgress(sessionId); + } + + this.UpdateCursor(sessionId, run.LastCheckpoint); + } + } + finally + { + this._workflowLock.Release(); + } + } + private HostedWorkflowRunResult Record(string sessionId, List events, CheckpointInfo? checkpoint) + { + this.UpdateCursor(sessionId, checkpoint); + return new HostedWorkflowRunResult(sessionId, events, checkpoint); + } + + private void UpdateCursor(string sessionId, CheckpointInfo? checkpoint) { if (checkpoint is not null) { this._cursor[sessionId] = checkpoint; } - - return new HostedWorkflowRunResult(sessionId, events, checkpoint); } + private void WarnOnNoProgress(string sessionId) + // The resumed turn drove no work. This mirrors the Python host's zero-event restore warning: the + // checkpoint may be stale or the input may not match the workflow's expected type, so the session's + // state may not have progressed. + => this._logger.LogWarning( + "Resuming workflow session '{SessionId}' produced no events; the checkpoint may be stale or the input may not match the workflow's expected input type. Session state may not have progressed.", + sessionId); + /// /// Gets the recorded head checkpoint for , if any. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs index aa41cf21ea3..f194a4245f8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs @@ -253,6 +253,44 @@ public async Task RunOrResumeAsync_ThirdTurn_KeepsAdvancingCheckpointAsync() Assert.Same(r3.Checkpoint, head); } + [Fact] + public async Task RunOrResumeStreamingAsync_StreamsEventsAndResumesAsync() + { + // Arrange + var state = new HostedWorkflowState(CreateEchoWorkflow()); + + // Act: first turn streamed. + List firstEvents = []; + await foreach (WorkflowEvent evt in state.RunOrResumeStreamingAsync("s1", InputMessages("hello"))) + { + firstEvents.Add(evt); + } + + // Assert: events streamed and the checkpoint was recorded after the stream completed. + Assert.NotEmpty(firstEvents); + Assert.True(state.TryGetCheckpoint("s1", out CheckpointInfo? firstCheckpoint)); + Assert.NotNull(firstCheckpoint); + + // Act: second turn streamed via the resume path with new input. + List secondEvents = []; + await foreach (WorkflowEvent evt in state.RunOrResumeStreamingAsync("s1", InputMessages("world"))) + { + secondEvents.Add(evt); + } + + // Assert: the resumed stream processed the new input and advanced the checkpoint. + string output = string.Concat( + secondEvents + .OfType() + .Select(e => e.Data) + .OfType>() + .SelectMany(messages => messages) + .Select(m => m.Text)); + Assert.Contains("world", output); + Assert.True(state.TryGetCheckpoint("s1", out CheckpointInfo? secondCheckpoint)); + Assert.NotSame(firstCheckpoint, secondCheckpoint); + } + private static List InputMessages(string text) => [new(ChatRole.User, text)]; private static string OutputText(HostedWorkflowRunResult result) => From 8693214f8a24420686c5d75cbe091fd86b56cfa4 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:36:04 +0100 Subject: [PATCH 10/28] .NET: Cover Responses input adaptation to a typed workflow start executor Demonstrate that HostedWorkflowState's generic RunOrResumeAsync is the input-adaptation seam (parity with Python's ResponsesChannel run hook): the app adapts the Responses input into the workflow start executor's own type at the call site. Add a typed-brief workflow and a test, and note the seam in spec-003. --- .../003-dotnet-hosting-protocol-helpers.md | 4 +++ .../BriefWorkflow.cs | 36 +++++++++++++++++++ .../HostedWorkflowStateTests.cs | 23 ++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/BriefWorkflow.cs diff --git a/docs/specs/003-dotnet-hosting-protocol-helpers.md b/docs/specs/003-dotnet-hosting-protocol-helpers.md index bd42bc2185b..c768a323591 100644 --- a/docs/specs/003-dotnet-hosting-protocol-helpers.md +++ b/docs/specs/003-dotnet-hosting-protocol-helpers.md @@ -149,6 +149,10 @@ one lock (mirroring the Python host's workflow lock); `HostedWorkflowState` is t streaming counterpart, `RunOrResumeStreamingAsync`, yields the turn's `WorkflowEvent`s as they occur (for example to render agent updates over the Responses SSE wire) and records the head checkpoint once the stream is fully enumerated, keeping the blocking and streaming workflow paths in lockstep as Python does. +Because `RunOrResumeAsync`/`RunOrResumeStreamingAsync` are generic over the input type, the application +adapts the Responses input into the workflow's start-executor input type at the call site (for example +parsing a structured payload into a typed record) — the .NET counterpart of Python's `ResponsesChannel` +run hook, without coupling the holder to a specific wire type. ## Non-goals for v1 diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/BriefWorkflow.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/BriefWorkflow.cs new file mode 100644 index 00000000000..24ecb003d40 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/BriefWorkflow.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Builds a workflow whose start executor accepts a typed (not +/// List<ChatMessage>) and yields a formatted string. Used to demonstrate that an application can +/// adapt Responses input into a workflow's native start-executor input via the generic +/// RunOrResumeAsync<TInput> (the .NET counterpart of Python's ResponsesChannel run hook). +/// +internal static class BriefWorkflow +{ + internal sealed record WriterBrief(string Topic, string Style); + + internal static Workflow Build() + { + var writer = new BriefExecutor("brief"); + return new WorkflowBuilder(writer) + .WithOutputFrom(writer) + .Build(); + } + + private sealed class BriefExecutor(string id) : Executor(id) + { + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) + => protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(this.HandleAsync)) + .YieldsOutput(); + + private ValueTask HandleAsync(WriterBrief brief, IWorkflowContext context, CancellationToken cancellationToken = default) + => context.YieldOutputAsync($"[{brief.Style}] {brief.Topic}", cancellationToken); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs index f194a4245f8..feef6d077f9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows; @@ -291,6 +292,28 @@ public async Task RunOrResumeStreamingAsync_StreamsEventsAndResumesAsync() Assert.NotSame(firstCheckpoint, secondCheckpoint); } + [Fact] + public async Task RunOrResumeAsync_AdaptsResponsesInputToTypedStartExecutorAsync() + { + // Arrange: a workflow whose start executor takes a typed WriterBrief rather than List. + // The application adapts the Responses input into that type before calling RunOrResumeAsync — the + // generic TInput is the .NET counterpart of Python's ResponsesChannel run hook. + var state = new HostedWorkflowState(BriefWorkflow.Build()); + + // Simulate parsing a structured Responses text payload into the start executor's input type. + const string responsesText = "{\"topic\":\"electric SUV\",\"style\":\"playful\"}"; + using JsonDocument doc = JsonDocument.Parse(responsesText); + var brief = new BriefWorkflow.WriterBrief( + doc.RootElement.GetProperty("topic").GetString()!, + doc.RootElement.GetProperty("style").GetString()!); + + // Act + HostedWorkflowRunResult result = await state.RunOrResumeAsync("s1", brief); + + // Assert: the adapted input drove the typed start executor. + Assert.Contains("[playful] electric SUV", StringOutput(result)); + } + private static List InputMessages(string text) => [new(ChatRole.User, text)]; private static string OutputText(HostedWorkflowRunResult result) => From 18332c947279717ccdae33c1534a139729d9d5f7 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:47:01 +0100 Subject: [PATCH 11/28] .NET: Drain workflow resume non-blocking to prevent hang and truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resume drain used a SuperStepCompletedEvent{HasPendingRequests} proxy over the blocking public WatchStreamAsync. That proxy (a) truncated a resumed turn when a superstep both emitted a request and queued downstream work, and (b) could fail to fire at all — re-introducing the indefinite hang — when a resume input drove no superstep (e.g. a rejected non-chat input). Make StreamingRun.WatchStreamAsync(bool blockOnPendingRequest, CancellationToken) public and drain both the blocking and streaming resume paths with blockOnPendingRequest:false, exactly matching the first-turn RunAsync semantics (Run.RunToNextHaltAsync). Add guard tests: resume with a rejected input does not hang, and a resume superstep with a request plus downstream work is not truncated (verified red against the old proxy). --- .../HostedWorkflowState.cs | 23 +++----- .../StreamingRun.cs | 19 +++++- .../FanOutRequestWorkflow.cs | 59 +++++++++++++++++++ .../HostedWorkflowStateTests.cs | 42 +++++++++++++ 4 files changed, 126 insertions(+), 17 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/FanOutRequestWorkflow.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs index 330a75b2e18..00175d484ef 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs @@ -148,18 +148,12 @@ private async ValueTask RunOrResumeCoreAsync(st } List events = []; - await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(cancellationToken).ConfigureAwait(false)) + // Drain non-blocking on pending requests, matching the first-turn RunAsync path + // (Run.RunToNextHaltAsync also uses blockOnPendingRequest: false): the workflow may halt awaiting an + // external response, and blocking there would wait indefinitely. + await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken).ConfigureAwait(false)) { events.Add(evt); - - // Stop draining when the resumed workflow halts awaiting an external response. The public - // stream blocks indefinitely on an unserviced pending request, whereas the first-turn - // RunAsync path returns at this same halt (Run.RunToNextHaltAsync uses non-blocking drain). - // SuperStepCompletedEvent marks the end of the pausing superstep and carries the flag. - if (evt is SuperStepCompletedEvent { CompletionInfo.HasPendingRequests: true }) - { - break; - } } if (events.Count == 0) @@ -225,15 +219,12 @@ public async IAsyncEnumerable RunOrResumeStreamingAsync(s } int eventCount = 0; - await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false)) + // Drain non-blocking on pending requests (see RunOrResumeCoreAsync) so a workflow that halts + // awaiting an external response ends the stream instead of blocking indefinitely. + await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken).ConfigureAwait(false)) { eventCount++; yield return evt; - - if (evt is SuperStepCompletedEvent { CompletionInfo.HasPendingRequests: true }) - { - break; - } } if (eventCount == 0 && head is not null) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs index c7a19380b2b..db8e449177e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs @@ -77,7 +77,24 @@ public IAsyncEnumerable WatchStreamAsync( CancellationToken cancellationToken = default) => this.WatchStreamAsync(blockOnPendingRequest: true, cancellationToken); - internal IAsyncEnumerable WatchStreamAsync( + /// + /// Asynchronously streams workflow events as they occur, with control over how the stream behaves when the + /// workflow halts awaiting an external response. + /// + /// + /// When is (the default behavior of + /// ), the stream pauses at a pending-request halt and resumes + /// once a response is supplied via . When it is , the + /// stream instead ends at that halt, returning control to the caller — matching the non-streaming + /// behavior, which is useful for hosts that drive their own turn loop. + /// + /// to block at a pending-request halt awaiting a + /// response; to end the stream at that halt. + /// A that can be used to cancel the streaming + /// operation. If cancellation is requested, the stream will end and no further events will be yielded, but this + /// will not cancel the workflow execution. + /// An asynchronous stream of objects representing significant workflow state changes. + public IAsyncEnumerable WatchStreamAsync( bool blockOnPendingRequest, CancellationToken cancellationToken = default) => this._runHandle.TakeEventStreamAsync(blockOnPendingRequest, cancellationToken); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/FanOutRequestWorkflow.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/FanOutRequestWorkflow.cs new file mode 100644 index 00000000000..dce4af95bc9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/FanOutRequestWorkflow.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Builds a workflow whose start executor, in a single superstep, both emits an external request (to a +/// ) and queues a message to a downstream executor that yields output. Used to +/// verify that resuming does not truncate the turn at the request-bearing superstep before the downstream +/// executor runs. +/// +internal static class FanOutRequestWorkflow +{ + internal const string RequestPortId = "approval"; + internal const string DownstreamPrefix = "downstream:"; + + internal static Workflow Build() + { + var start = new FanOutExecutor("start", RequestPortId, "downstream"); + RequestPort port = RequestPort.Create(RequestPortId); + var downstream = new DownstreamExecutor("downstream"); + + return new WorkflowBuilder(start) + .AddEdge(start, port) + .AddEdge(start, downstream) + .WithOutputFrom(downstream) + .Build(); + } + + internal sealed record ApprovalRequest(string Prompt); + + private sealed class FanOutExecutor(string id, string requestPortId, string downstreamId) : Executor(id) + { + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) + => protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(this.HandleAsync)) + .SendsMessage() + .SendsMessage(); + + private async ValueTask HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken = default) + { + // Same superstep: emit an external request AND queue downstream work. + await context.SendMessageAsync(new ApprovalRequest(input), requestPortId, cancellationToken).ConfigureAwait(false); + await context.SendMessageAsync(input, downstreamId, cancellationToken).ConfigureAwait(false); + } + } + + private sealed class DownstreamExecutor(string id) : Executor(id) + { + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) + => protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(this.HandleAsync)) + .YieldsOutput(); + + private ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) + => context.YieldOutputAsync($"{DownstreamPrefix}{message}", cancellationToken); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs index feef6d077f9..c71b6c8d81d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs @@ -314,6 +314,48 @@ public async Task RunOrResumeAsync_AdaptsResponsesInputToTypedStartExecutorAsync Assert.Contains("[playful] electric SUV", StringOutput(result)); } + [Fact] + public async Task RunOrResumeAsync_ResumeWithRejectedInput_DoesNotHangAsync() + { + // Arrange: a non-chat human-in-the-loop workflow whose first turn emits a request and halts. + var state = new HostedWorkflowState(ApprovalGateWorkflow.Build()); + HostedWorkflowRunResult first = await state.RunOrResumeAsync("s1", "approve") + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(30)); + Assert.Contains(first.Events, e => e is RequestInfoEvent); + + // Act: resume with an input the start executor cannot handle (wrong type), so no superstep runs. + // A drain that blocks on the restored pending request would hang here; guard with a timeout. + HostedWorkflowRunResult second = await state.RunOrResumeAsync("s1", 42) + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(30)); + + // Assert: it returned (surfacing the restored pending request) rather than blocking indefinitely. + Assert.Contains(second.Events, e => e is RequestInfoEvent); + } + + [Fact] + public async Task RunOrResumeAsync_ResumeSuperstepWithRequestAndDownstream_DoesNotTruncateAsync() + { + // Arrange: a workflow whose start executor, in one superstep, emits a request AND queues a message to + // a downstream executor that yields output. The first turn establishes a checkpoint. + var state = new HostedWorkflowState(FanOutRequestWorkflow.Build()); + HostedWorkflowRunResult first = await state.RunOrResumeAsync("s1", "one") + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(30)); + Assert.Contains(first.Events, e => e is RequestInfoEvent); + + // Act: resume with new input, which again fans out to the request port and the downstream executor. + HostedWorkflowRunResult second = await state.RunOrResumeAsync("s1", "two") + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(30)); + + // Assert: the resumed turn drained past the request-bearing superstep so the downstream output is + // present (a drain that broke at the request would truncate it). + Assert.Contains(second.Events, e => e is RequestInfoEvent); + Assert.Contains(FanOutRequestWorkflow.DownstreamPrefix, StringOutput(second)); + } + private static List InputMessages(string text) => [new(ChatRole.User, text)]; private static string OutputText(HostedWorkflowRunResult result) => From 6344bdf444535f6145d47a9be6e77fd364a8a9bd Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:54:02 +0100 Subject: [PATCH 12/28] .NET: Return file-store checkpoint index in commit order CheckpointManager.GetLatestCheckpointAsync takes the last entry of a store's index as the head checkpoint. FileSystemJsonCheckpointStore backed its index with a HashSet, whose enumeration order is not contractual: after a rollback frees and reuses a slot, enumeration can diverge from commit order, so the durable read-through could resume a stale checkpoint. Mirror the HashSet with an insertion-ordered list and enumerate it from RetrieveIndexAsync so 'latest' is reliable. Add a CheckpointManager.GetLatestCheckpointAsync contract test over the file store. Note: the HashSet disorder is only reachable via the internal rollback path, so the test locks the ordering contract rather than reproducing the rare disorder. --- .../FileSystemJsonCheckpointStore.cs | 18 +++++++- .../CheckpointManagerLatestTests.cs | 44 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointManagerLatestTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs index 0c3d57976d2..bb927b3a825 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs @@ -35,6 +35,13 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos internal DirectoryInfo Directory { get; } internal HashSet CheckpointIndex { get; } + + // Insertion-ordered mirror of CheckpointIndex. HashSet enumeration order is not a contract (it can diverge + // from insertion order once a slot is freed by a rollback and reused), so RetrieveIndexAsync enumerates this + // list to return checkpoints in commit order, which callers such as CheckpointManager.GetLatestCheckpointAsync + // rely on to identify the head checkpoint. + private List OrderedCheckpointIndex { get; } = []; + private Dictionary CheckpointParents { get; } = []; private HashSet CheckpointsWithKnownParent { get; } = []; @@ -80,7 +87,11 @@ public FileSystemJsonCheckpointStore(DirectoryInfo directory) { // We never actually use the file names from the index entries since they can be derived from the CheckpointInfo, but it is useful to // have the UrlEncoded file names in the index file for human readability - this.CheckpointIndex.Add(entry.CheckpointInfo); + if (this.CheckpointIndex.Add(entry.CheckpointInfo)) + { + this.OrderedCheckpointIndex.Add(entry.CheckpointInfo); + } + this.CheckpointParents[entry.CheckpointInfo] = entry.ParentCheckpointId; if (entry.HasParentMetadata) { @@ -129,6 +140,8 @@ private CheckpointInfo GetUnusedCheckpointInfo(string sessionId) key = new(sessionId); } while (!this.CheckpointIndex.Add(key)); + this.OrderedCheckpointIndex.Add(key); + return key; } @@ -164,6 +177,7 @@ public override async ValueTask CreateCheckpointAsync(string ses catch (Exception ex) { this.CheckpointIndex.Remove(key); + this.OrderedCheckpointIndex.Remove(key); this.CheckpointParents.Remove(key); this.CheckpointsWithKnownParent.Remove(key); @@ -202,7 +216,7 @@ public override ValueTask> RetrieveIndexAsync(string { this.CheckDisposed(); - return new(this.CheckpointIndex + return new(this.OrderedCheckpointIndex .Where(checkpoint => checkpoint.SessionId == sessionId && (withParent is null || !this.CheckpointsWithKnownParent.Contains(checkpoint) || diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointManagerLatestTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointManagerLatestTests.cs new file mode 100644 index 00000000000..2bc8b7393e8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointManagerLatestTests.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Checkpointing; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +/// +/// Tests for , which must return the most recently +/// committed checkpoint for a session regardless of the backing store implementation. +/// +public class CheckpointManagerLatestTests +{ + [Fact] + public async Task GetLatestCheckpointAsync_FileStore_ReturnsLastCommittedAsync() + { + // Arrange: commit a chain of checkpoints to a durable file store in a known order. + using TempDirectory dir = new(); + using FileSystemJsonCheckpointStore store = new(dir); + CheckpointManager manager = CheckpointManager.CreateJson(store); + + const string SessionId = "session-latest"; + List committed = []; + CheckpointInfo? parent = null; + for (int i = 0; i < 8; i++) + { + JsonElement value = JsonSerializer.SerializeToElement($"checkpoint-{i}"); + CheckpointInfo info = await store.CreateCheckpointAsync(SessionId, value, parent); + committed.Add(info); + parent = info; + } + + // Act + IEnumerable index = await store.RetrieveIndexAsync(SessionId); + CheckpointInfo? latest = await manager.GetLatestCheckpointAsync(SessionId); + + // Assert: the durable index preserves commit order, so the latest checkpoint is the last committed. + index.Should().Equal(committed, "the file-store index should be returned in commit order"); + latest.Should().Be(committed[^1]); + } +} From 0d1c81044d5e146c649e5d87e1e71ddf959932cd Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:56:55 +0100 Subject: [PATCH 13/28] .NET: Advance cursor when a streaming resume is abandoned RunOrResumeStreamingAsync recorded the head checkpoint only after the stream was fully enumerated. If an SSE consumer disconnected mid-turn after supersteps had committed, the in-memory cursor kept the previous turn's head; because the next turn is then a cursor hit, durable read-through could not self-heal, so it resumed pre-disconnect state. Record the run's last committed checkpoint in a finally so an abandoned stream still advances the cursor. Add a red/green test. --- .../HostedWorkflowState.cs | 38 +++++++++++-------- .../HostedWorkflowStateTests.cs | 26 +++++++++++++ 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs index 00175d484ef..7f0b0b07a75 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs @@ -169,14 +169,14 @@ private async ValueTask RunOrResumeCoreAsync(st /// Streams the events of a run-or-resume turn as they occur, applying the same restore-then-run semantics as /// : the first turn runs the workflow /// forward from its start executor, and subsequent turns restore the session's latest checkpoint and run - /// forward with . The session's head checkpoint is recorded once the stream is fully - /// enumerated. + /// forward with . The session's head checkpoint is recorded when the stream ends, + /// including when the consumer abandons enumeration early. /// /// /// Turns are serialized through the holder's workflow lock, which is held for the lifetime of the returned - /// enumerator. The caller must enumerate the stream to completion (or dispose it) to release the lock. Because - /// the head checkpoint is recorded only after the stream completes, abandoning the enumeration early does not - /// advance the session cursor. + /// enumerator. The caller must enumerate the stream to completion (or dispose it) to release the lock. The head + /// checkpoint is recorded from the run's last committed checkpoint when the stream ends — whether it completes + /// normally or the consumer disposes it early — so an interrupted turn still advances the session cursor. /// /// The workflow input type. /// The application-selected session id. @@ -219,20 +219,28 @@ public async IAsyncEnumerable RunOrResumeStreamingAsync(s } int eventCount = 0; - // Drain non-blocking on pending requests (see RunOrResumeCoreAsync) so a workflow that halts - // awaiting an external response ends the stream instead of blocking indefinitely. - await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken).ConfigureAwait(false)) + try { - eventCount++; - yield return evt; - } + // Drain non-blocking on pending requests (see RunOrResumeCoreAsync) so a workflow that halts + // awaiting an external response ends the stream instead of blocking indefinitely. + await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken).ConfigureAwait(false)) + { + eventCount++; + yield return evt; + } - if (eventCount == 0 && head is not null) + if (eventCount == 0 && head is not null) + { + this.WarnOnNoProgress(sessionId); + } + } + finally { - this.WarnOnNoProgress(sessionId); + // Record the head checkpoint even when the consumer abandons the stream (for example an SSE + // client disconnect), so an interrupted turn still advances the session cursor to the last + // committed checkpoint and a later turn resumes from there rather than re-running prior work. + this.UpdateCursor(sessionId, run.LastCheckpoint); } - - this.UpdateCursor(sessionId, run.LastCheckpoint); } } finally diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs index c71b6c8d81d..85c1bd029b3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs @@ -356,6 +356,32 @@ public async Task RunOrResumeAsync_ResumeSuperstepWithRequestAndDownstream_DoesN Assert.Contains(FanOutRequestWorkflow.DownstreamPrefix, StringOutput(second)); } + [Fact] + public async Task RunOrResumeStreamingAsync_AbandonedAfterCheckpoint_AdvancesCursorAsync() + { + // Arrange + var state = new HostedWorkflowState(CreateEchoWorkflow()); + await foreach (WorkflowEvent _ in state.RunOrResumeStreamingAsync("s1", InputMessages("a"))) + { + // Enumerate the first turn to completion so the cursor holds its head checkpoint. + } + Assert.True(state.TryGetCheckpoint("s1", out CheckpointInfo? cp1)); + + // Act: abandon the second turn after a superstep has committed a checkpoint. + await foreach (WorkflowEvent evt in state.RunOrResumeStreamingAsync("s1", InputMessages("b"))) + { + if (evt is SuperStepCompletedEvent { CompletionInfo.Checkpoint: not null }) + { + break; + } + } + + // Assert: the abandoned turn still advanced the cursor to the last committed checkpoint, so a later + // turn resumes from there rather than re-running from the previous head. + Assert.True(state.TryGetCheckpoint("s1", out CheckpointInfo? cp2)); + Assert.NotEqual(cp1, cp2); + } + private static List InputMessages(string text) => [new(ChatRole.User, text)]; private static string OutputText(HostedWorkflowRunResult result) => From 854b28d07297a750772ecd6e39990d1fc106dfe1 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:00:05 +0100 Subject: [PATCH 14/28] .NET: Stream only the final agent's updates in the workflow sample ExtractUpdates streamed every agent's updates, so the sequential Writer->Reviewer sample streamed the intermediate draft and the final answer over SSE, differing from the non-streaming response (final message only). Filter the streamed updates to the final agent so streaming and non-streaming produce the same response. Live-verified against Foundry: one output item streamed instead of two. --- .../04-hosting/HostingResponsesWorkflow/Program.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs b/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs index 37dd8586f5e..336a46d6391 100644 --- a/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs +++ b/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs @@ -30,6 +30,10 @@ Workflow workflow = AgentWorkflowBuilder.BuildSequential(workflowName: "WriteAndReview", agents: [writer, reviewer]); +// The last agent in the sequential pipeline produces the workflow's final answer. The streaming path filters +// updates to this agent so the streamed response matches the non-streaming final-message response. +string finalAgentName = reviewer.Name ?? "Reviewer"; + // Optional shared execution state: pairs the workflow with an in-memory CheckpointManager and a per-session // sessionId -> CheckpointInfo head cursor so a session can resume from its last checkpoint. var state = new HostedWorkflowState(workflow); @@ -59,7 +63,7 @@ // turn's input thereafter, recording the new head checkpoint once the stream completes. http.Response.ContentType = "text/event-stream"; string streamResponseId = OpenAIResponses.CreateResponseId(); - IAsyncEnumerable updates = ExtractUpdates(state.RunOrResumeStreamingAsync(sessionId, messages, cancellationToken), cancellationToken); + IAsyncEnumerable updates = ExtractUpdates(state.RunOrResumeStreamingAsync(sessionId, messages, cancellationToken), finalAgentName, cancellationToken); await foreach (string frame in OpenAIResponses.WriteResponseStreamAsync(updates, streamResponseId, sessionId, cancellationToken).ConfigureAwait(false)) { @@ -82,15 +86,17 @@ app.Run(); -// Projects the workflow's per-agent streaming updates from the emitted workflow events, so the app can -// render them over the Responses SSE wire. +// Projects the final agent's streaming updates from the emitted workflow events, so the streamed response +// matches the non-streaming final-message response rather than also streaming intermediate agents' drafts. static async IAsyncEnumerable ExtractUpdates( IAsyncEnumerable events, + string finalAgentName, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { await foreach (WorkflowEvent evt in events.WithCancellation(cancellationToken).ConfigureAwait(false)) { - if (evt is AgentResponseUpdateEvent updateEvent) + if (evt is AgentResponseUpdateEvent updateEvent && + string.Equals(updateEvent.Update.AuthorName, finalAgentName, StringComparison.Ordinal)) { yield return updateEvent.Update; } From 06ff24bcbe5b01e4c249c0236bf47a12dcba1c49 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:02:27 +0100 Subject: [PATCH 15/28] .NET: Isolate the holder lock in the concurrency test The concurrency test asserted the second same-session turn did not enter the workflow, which also passes via the engine's concurrent-run ownership guard (which faults) rather than the holder lock (which waits). Assert instead that the second turn is not completed while the first holds the lock: a fault would complete the task, so a pending task isolates the holder lock from the engine guard. Verified red with the lock removed. --- .../HostedWorkflowStateTests.cs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs index 85c1bd029b3..3fc36bf76c8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs @@ -194,16 +194,20 @@ public async Task RunOrResumeAsync_ConcurrentSameSessionTurns_AreSerializedAsync // Start a second same-session turn while the first is still inside the workflow. Task second = state.RunOrResumeAsync("s1", "go").AsTask(); - // Assert: the second turn must NOT enter the workflow while the first holds it (a shared workflow - // instance does not support concurrent runs). Without serialization it would enter concurrently. - bool secondEntered = await entered.WaitAsync(TimeSpan.FromSeconds(1)); - Assert.False(secondEntered, "the second same-session turn must wait for the first to complete"); + // Assert: the second turn must WAIT on the holder lock, not fault. Without the lock it would reach the + // engine's concurrent-run ownership guard and fault (completing the task); the lock instead leaves it + // pending until the first turn releases. Checking the task is not completed isolates the holder lock + // from the engine guard, and the entered gate confirms it did not run concurrently. + await Task.Delay(TimeSpan.FromMilliseconds(500)); + Assert.False(second.IsCompleted, "the second turn must wait on the holder lock rather than fault or run concurrently"); + Assert.False(await entered.WaitAsync(TimeSpan.FromMilliseconds(200)), "the second turn must not enter the workflow while the first holds it"); // Release both turns and let them run to completion. release.SetResult(); HostedWorkflowRunResult[] results = await Task.WhenAll(first, second).WaitAsync(TimeSpan.FromSeconds(30)); - // The serialized turns advanced the count from 1 to 2. + // Both turns completed successfully (proving the lock serialized rather than faulted them), advancing + // the count from 1 to 2. string combined = string.Concat(results.Select(StringOutput)); Assert.Contains("count:1", combined); Assert.Contains("count:2", combined); From 294d181d46f474cbbe5606704904b523a1761a4e Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:19:23 +0100 Subject: [PATCH 16/28] Fix IDE1006 naming in tests; address review feedback and add hosting/live tests --- dotnet/agent-framework-dotnet.slnx | 4 +- .../HostingResponsesAgent/Program.cs | 11 +- .../HostingResponsesWorkflow/Program.cs | 8 +- .../HostedAgentState.cs | 14 +- .../HostedWorkflowState.cs | 41 ++- .../NoopAgentSessionStore.cs | 4 +- .../FileSystemJsonCheckpointStore.cs | 5 + ....AI.Hosting.OpenAI.IntegrationTests.csproj | 19 ++ .../OpenAIResponsesHostingLiveTests.cs | 96 +++++++ .../OpenAIResponsesHostingTests.cs | 254 ++++++++++++++++++ .../HostedAgentStateTests.cs | 14 +- .../HostedWorkflowStateTests.cs | 17 +- 12 files changed, 433 insertions(+), 54 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests.csproj create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index b3d08a874ab..f99c0395fe0 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -641,7 +641,9 @@ - + + + diff --git a/dotnet/samples/04-hosting/HostingResponsesAgent/Program.cs b/dotnet/samples/04-hosting/HostingResponsesAgent/Program.cs index f70e9762998..8236ed203ec 100644 --- a/dotnet/samples/04-hosting/HostingResponsesAgent/Program.cs +++ b/dotnet/samples/04-hosting/HostingResponsesAgent/Program.cs @@ -31,12 +31,10 @@ var app = builder.Build(); // The application owns this route. It parses the OpenAI Responses body with the helpers, runs the agent -// itself, and renders the response with the helpers. -app.MapPost("/responses", async (HttpContext http, CancellationToken cancellationToken) => +// itself, and renders the response with the helpers. Binding the body as JsonElement lets ASP.NET Core +// deserialize the JSON request body directly, so there is no JsonDocument to own or dispose. +app.MapPost("/responses", async (JsonElement body, HttpContext http, CancellationToken cancellationToken) => { - using JsonDocument doc = await JsonDocument.ParseAsync(http.Request.Body, cancellationToken: cancellationToken).ConfigureAwait(false); - JsonElement body = doc.RootElement; - // The candidate continuation id is untrusted. A real app authenticates the caller and authorizes/binds // this key to the principal before using it. This sample simply falls back to a fresh id. string? candidateSessionId = OpenAIResponses.GetSessionId(body); @@ -60,6 +58,9 @@ // Persist the post-run session under the new response id so the next turn can continue from it. await state.SaveSessionAsync(responseId, 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; } diff --git a/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs b/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs index 336a46d6391..77ddc1ed2a1 100644 --- a/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs +++ b/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs @@ -40,12 +40,10 @@ var app = builder.Build(); -// The application owns this route. -app.MapPost("/responses", async (HttpContext http, CancellationToken cancellationToken) => +// The application owns this route. Binding the body as JsonElement lets ASP.NET Core deserialize the JSON +// request body directly, so there is no JsonDocument to own or dispose. +app.MapPost("/responses", async (JsonElement body, HttpContext http, CancellationToken cancellationToken) => { - using JsonDocument doc = await JsonDocument.ParseAsync(http.Request.Body, cancellationToken: cancellationToken).ConfigureAwait(false); - JsonElement body = doc.RootElement; - // Workflow checkpoint resume needs a STABLE key across turns. previous_response_id changes every turn, // so key on the conversation id (constant for the conversation). The candidate is untrusted: a real app // authenticates the caller and authorizes/binds this key before using it. This sample falls back to a fresh id. diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs index f7bf055117f..fef9a0bf58d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs @@ -30,6 +30,7 @@ namespace Microsoft.Agents.AI.Hosting; /// public sealed class HostedAgentState { + private readonly AIAgent _agent; private readonly AgentSessionStore _sessionStore; private readonly ConcurrentDictionary? _sessionLocks; @@ -49,16 +50,11 @@ public HostedAgentState(AIAgent agent, AgentSessionStore? sessionStore = null, b { _ = Throw.IfNull(agent); - this.Agent = agent; + this._agent = agent; this._sessionStore = sessionStore ?? new InMemoryAgentSessionStore(); this._sessionLocks = enableSessionLocking ? new ConcurrentDictionary(StringComparer.Ordinal) : null; } - /// - /// Gets the agent target. - /// - public AIAgent Agent { get; } - /// /// Returns the stored session for , creating a new session on first use. /// @@ -68,7 +64,7 @@ public HostedAgentState(AIAgent agent, AgentSessionStore? sessionStore = null, b public ValueTask GetOrCreateSessionAsync(string sessionId, CancellationToken cancellationToken = default) { _ = Throw.IfNullOrEmpty(sessionId); - return this._sessionStore.GetSessionAsync(this.Agent, sessionId, cancellationToken); + return this._sessionStore.GetSessionAsync(this._agent, sessionId, cancellationToken); } /// @@ -83,7 +79,7 @@ public ValueTask SaveSessionAsync(string sessionId, AgentSession session, Cancel { _ = Throw.IfNullOrEmpty(sessionId); _ = Throw.IfNull(session); - return this._sessionStore.SaveSessionAsync(this.Agent, sessionId, session, cancellationToken); + return this._sessionStore.SaveSessionAsync(this._agent, sessionId, session, cancellationToken); } /// @@ -96,7 +92,7 @@ public ValueTask SaveSessionAsync(string sessionId, AgentSession session, Cancel public ValueTask DeleteSessionAsync(string sessionId, CancellationToken cancellationToken = default) { _ = Throw.IfNullOrEmpty(sessionId); - return this._sessionStore.DeleteSessionAsync(this.Agent, sessionId, cancellationToken); + return this._sessionStore.DeleteSessionAsync(this._agent, sessionId, cancellationToken); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs index 7f0b0b07a75..61ffdab0b20 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows; +using Microsoft.Agents.AI.Workflows.InProc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Shared.Diagnostics; @@ -40,6 +41,8 @@ namespace Microsoft.Agents.AI.Hosting; public sealed class HostedWorkflowState : IDisposable { private readonly CheckpointManager _checkpointManager; + private readonly IWorkflowExecutionEnvironment _executionEnvironment; + private readonly Workflow _workflow; private readonly ILogger _logger; private readonly ConcurrentDictionary _cursor = new(StringComparer.Ordinal); @@ -54,25 +57,32 @@ public sealed class HostedWorkflowState : IDisposable /// /// The checkpoint manager to use. Defaults to when not provided. /// + /// + /// The workflow execution environment used to run and resume the workflow. Defaults to an in-process environment + /// () configured with . Supplying a + /// custom environment (for example a future durable/out-of-process environment) is supported; the supplied + /// environment must be configured to checkpoint into the same store as , since + /// the holder reads that manager directly to recover the head checkpoint when its in-memory cursor misses. + /// /// /// The logger factory used to report resume diagnostics (for example, a resume turn that made no progress). /// Defaults to when not provided. /// /// is . - public HostedWorkflowState(Workflow workflow, CheckpointManager? checkpointManager = null, ILoggerFactory? loggerFactory = null) + public HostedWorkflowState( + Workflow workflow, + CheckpointManager? checkpointManager = null, + IWorkflowExecutionEnvironment? executionEnvironment = null, + ILoggerFactory? loggerFactory = null) { _ = Throw.IfNull(workflow); - this.Workflow = workflow; + this._workflow = workflow; this._checkpointManager = checkpointManager ?? CheckpointManager.CreateInMemory(); + this._executionEnvironment = executionEnvironment ?? InProcessExecution.Default.WithCheckpointing(this._checkpointManager); this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(typeof(HostedWorkflowState)); } - /// - /// Gets the workflow target. - /// - public Workflow Workflow { get; } - /// /// Runs the workflow forward for with checkpointing on the first turn, or, on /// subsequent turns, restores the session's recorded head checkpoint and then runs the workflow forward with @@ -123,7 +133,7 @@ private async ValueTask RunOrResumeCoreAsync(st if (head is null) { // First turn for this session: run the workflow forward from its start executor with the input. - Run freshRun = await InProcessExecution.RunAsync(this.Workflow, input, this._checkpointManager, sessionId, cancellationToken).ConfigureAwait(false); + Run freshRun = await this._executionEnvironment.RunAsync(this._workflow, input, sessionId, cancellationToken).ConfigureAwait(false); await using (freshRun.ConfigureAwait(false)) { return this.Record(sessionId, freshRun.OutgoingEvents.ToList(), freshRun.LastCheckpoint); @@ -136,9 +146,9 @@ private async ValueTask RunOrResumeCoreAsync(st // // The streaming resume restores state without draining to a halt first; the non-streaming resume would // block waiting for input immediately after restore (before we can deliver the new input). - ProtocolDescriptor descriptor = await this.Workflow.DescribeProtocolAsync(cancellationToken).ConfigureAwait(false); + ProtocolDescriptor descriptor = await this._workflow.DescribeProtocolAsync(cancellationToken).ConfigureAwait(false); - StreamingRun resumed = await InProcessExecution.ResumeStreamingAsync(this.Workflow, head, this._checkpointManager, cancellationToken).ConfigureAwait(false); + StreamingRun resumed = await this._executionEnvironment.ResumeStreamingAsync(this._workflow, head, cancellationToken).ConfigureAwait(false); await using (resumed.ConfigureAwait(false)) { await resumed.TrySendMessageAsync(input).ConfigureAwait(false); @@ -197,14 +207,14 @@ public async IAsyncEnumerable RunOrResumeStreamingAsync(s head = await this._checkpointManager.GetLatestCheckpointAsync(sessionId, cancellationToken).ConfigureAwait(false); } - ProtocolDescriptor descriptor = await this.Workflow.DescribeProtocolAsync(cancellationToken).ConfigureAwait(false); + ProtocolDescriptor descriptor = await this._workflow.DescribeProtocolAsync(cancellationToken).ConfigureAwait(false); // The fresh streaming run enqueues the input itself; the streaming resume restores state and needs the // input delivered explicitly. Neither streaming entry point seeds a TurnToken, so drive chat-protocol // workflows with one on both paths. StreamingRun run = head is null - ? await InProcessExecution.RunStreamingAsync(this.Workflow, input, this._checkpointManager, sessionId, cancellationToken).ConfigureAwait(false) - : await InProcessExecution.ResumeStreamingAsync(this.Workflow, head, this._checkpointManager, cancellationToken).ConfigureAwait(false); + ? await this._executionEnvironment.RunStreamingAsync(this._workflow, input, sessionId, cancellationToken).ConfigureAwait(false) + : await this._executionEnvironment.ResumeStreamingAsync(this._workflow, head, cancellationToken).ConfigureAwait(false); await using (run.ConfigureAwait(false)) { @@ -277,7 +287,10 @@ private void WarnOnNoProgress(string sessionId) /// The application-selected session id. /// When this method returns, the recorded head checkpoint, or . /// when a checkpoint is recorded for the session; otherwise . - public bool TryGetCheckpoint(string sessionId, out CheckpointInfo? checkpoint) + /// + /// Internal cursor-inspection helper used by tests; not part of the public surface. + /// + internal bool TryGetCheckpoint(string sessionId, out CheckpointInfo? checkpoint) { _ = Throw.IfNullOrEmpty(sessionId); return this._cursor.TryGetValue(sessionId, out checkpoint); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs index 34cb2c998d6..cc1df02a33b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs @@ -14,7 +14,7 @@ public sealed class NoopAgentSessionStore : AgentSessionStore /// public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) { - return new ValueTask(); + return default; } /// @@ -26,6 +26,6 @@ public override ValueTask GetSessionAsync(AIAgent agent, string co /// public override ValueTask DeleteSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) { - return new ValueTask(); + return default; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs index bb927b3a825..cb0891d6179 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs @@ -34,6 +34,11 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos private FileStream? _indexFile; internal DirectoryInfo Directory { get; } + + // O(1) membership set used to allocate unique checkpoint ids (GetUnusedCheckpointInfo) and to guard + // RetrieveCheckpointAsync. It intentionally coexists with OrderedCheckpointIndex below: this set answers + // "does this checkpoint exist?" in O(1), while the list preserves commit order. A single HashSet cannot do + // both because HashSet enumeration order is not a contract. internal HashSet CheckpointIndex { get; } // Insertion-ordered mirror of CheckpointIndex. HashSet enumeration order is not a contract (it can diverge diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests.csproj new file mode 100644 index 00000000000..94fc6720017 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests.csproj @@ -0,0 +1,19 @@ + + + + $(TargetFrameworksCore) + True + $(NoWarn);OPENAI001; + + + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs new file mode 100644 index 00000000000..04819bc65ef --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using OpenAI; +using Shared.IntegrationTests; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests; + +/// +/// Live integration tests for the app-owned routing helper surface ( plus +/// ) exercised against a real OpenAI model. These confirm the crucial +/// consumption paths — request conversion, an agent run, response rendering, and multi-turn session +/// continuity — behave correctly end to end with a real chat client. +/// +/// +/// Skipped unless the OpenAI configuration is present (OPENAI_API_KEY), so runs without secrets stay +/// green. The in-process, in-memory (no live model) coverage lives in the +/// Microsoft.Agents.AI.Hosting.OpenAI.UnitTests project (OpenAIResponsesHostingTests). +/// +public sealed class OpenAIResponsesHostingLiveTests +{ + private static string? ApiKey => Environment.GetEnvironmentVariable(TestSettings.OpenAIApiKey); + private static string ModelName => Environment.GetEnvironmentVariable(TestSettings.OpenAIChatModelName) ?? "gpt-4o-mini"; + + [Fact] + public async Task NonStreamingRun_RendersResponsesShapedPayloadAsync() + { + // Arrange + Assert.SkipWhen(string.IsNullOrEmpty(ApiKey), "OPENAI_API_KEY is not configured; skipping live hosting test."); + AIAgent agent = CreateAgent(); + var state = new HostedAgentState(agent); + JsonElement body = ParseBody("""{ "input": "Reply with exactly the word: apple" }"""); + + // Act + string sessionId = OpenAIResponses.GetSessionId(body) ?? OpenAIResponses.CreateResponseId(); + OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); + AgentSession session = await state.GetOrCreateSessionAsync(sessionId); + string responseId = OpenAIResponses.CreateResponseId(); + AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options); + JsonElement payload = OpenAIResponses.WriteResponse(result, responseId, responseId); + + // Assert + Assert.Equal(responseId, payload.GetProperty("id").GetString()); + Assert.Equal("response", payload.GetProperty("object").GetString()); + Assert.Contains("output", payload.EnumerateObject().Select(p => p.Name)); + } + + [Fact] + public async Task MultiTurn_ContinuesSessionAcrossTurnsAsync() + { + // Arrange + Assert.SkipWhen(string.IsNullOrEmpty(ApiKey), "OPENAI_API_KEY is not configured; skipping live hosting test."); + AIAgent agent = CreateAgent(); + var state = new HostedAgentState(agent); + + // Act: first turn establishes context, second turn continues from the first response id. + string firstResponseId = await RunTurnAsync(agent, state, """{ "input": "Remember the number 7." }"""); + JsonElement secondBody = ParseBody($$"""{ "input": "What number did I ask you to remember?", "previous_response_id": "{{firstResponseId}}" }"""); + string secondSessionId = OpenAIResponses.GetSessionId(secondBody)!; + OpenAIResponsesRunRequest secondRun = OpenAIResponses.ToAgentRunRequest(secondBody); + AgentSession session = await state.GetOrCreateSessionAsync(secondSessionId); + AgentResponse secondResult = await agent.RunAsync(secondRun.Messages, session, secondRun.Options); + + // Assert: continuation succeeded and the model produced a textual answer. + Assert.Equal(secondSessionId, firstResponseId); + Assert.False(string.IsNullOrWhiteSpace(secondResult.Text)); + } + + private static async Task RunTurnAsync(AIAgent agent, HostedAgentState state, string bodyJson) + { + JsonElement body = ParseBody(bodyJson); + string sessionId = OpenAIResponses.GetSessionId(body) ?? OpenAIResponses.CreateResponseId(); + OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); + AgentSession session = await state.GetOrCreateSessionAsync(sessionId); + string responseId = OpenAIResponses.CreateResponseId(); + _ = await agent.RunAsync(run.Messages, session, run.Options); + await state.SaveSessionAsync(responseId, session); + return responseId; + } + + private static ChatClientAgent CreateAgent() => + new( + new OpenAIClient(ApiKey).GetChatClient(ModelName).AsIChatClient(), + instructions: "You are a concise assistant.", + name: "assistant"); + + private static JsonElement ParseBody(string json) + { + using JsonDocument doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs new file mode 100644 index 00000000000..2712127daa3 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs @@ -0,0 +1,254 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; +using Microsoft.Agents.AI.Workflows; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.Tests; + +/// +/// In-process, in-memory hosting tests for : a request travels through an +/// app-owned ASP.NET Core route (hosted in-memory via ) that wires +/// plus / , +/// exactly like the HostingResponsesAgent / HostingResponsesWorkflow samples. These run fully +/// in-process against a deterministic mock chat client — there is no external server process and no live +/// model. Live-model coverage lives in the separate Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests +/// project. This mirrors the Python test_http_round_trip.py coverage. +/// +public sealed class OpenAIResponsesHostingTests : IAsyncDisposable +{ + private WebApplication? _app; + private HttpClient? _client; + + [Fact] + public async Task AgentRoute_NonStreaming_ReturnsResponsesShapedJsonAsync() + { + // Arrange + HttpClient client = await this.StartAgentHostAsync(new TestHelpers.ConversationMemoryMockChatClient("Hello from the agent")); + + // Act + HttpResponseMessage response = await client.PostAsync( + new Uri("/responses", UriKind.Relative), + JsonContent("""{ "input": "Hi there" }""")); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using JsonDocument doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + JsonElement root = doc.RootElement; + Assert.StartsWith("resp_", root.GetProperty("id").GetString()); + Assert.Equal("response", root.GetProperty("object").GetString()); + Assert.Contains("Hello from the agent", root.GetRawText()); + } + + [Fact] + public async Task AgentRoute_Streaming_ReturnsServerSentEventsAsync() + { + // Arrange + HttpClient client = await this.StartAgentHostAsync(new TestHelpers.ConversationMemoryMockChatClient("Streamed answer")); + + // Act + HttpResponseMessage response = await client.PostAsync( + new Uri("/responses", UriKind.Relative), + JsonContent("""{ "input": "Hi there", "stream": true }""")); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/event-stream", response.Content.Headers.ContentType?.MediaType); + string body = await response.Content.ReadAsStringAsync(); + Assert.Contains("event: response.created", body, StringComparison.Ordinal); + Assert.Contains("event: response.completed", body, StringComparison.Ordinal); + Assert.Contains("Streamed answer", body, StringComparison.Ordinal); + } + + [Fact] + public async Task AgentRoute_MultiTurn_ReusesSessionAcrossTurnsAsync() + { + // Arrange: a recording mock so we can prove the second turn saw the first turn's history. + var recorder = new TestHelpers.ConversationMemoryMockChatClient("ok"); + HttpClient client = await this.StartAgentHostAsync(recorder); + + // Act: first turn, then continue using the returned response id as previous_response_id. + using JsonDocument first = JsonDocument.Parse(await (await client.PostAsync( + new Uri("/responses", UriKind.Relative), JsonContent("""{ "input": "first turn" }"""))).Content.ReadAsStringAsync()); + string responseId = first.RootElement.GetProperty("id").GetString()!; + + HttpResponseMessage secondResponse = await client.PostAsync( + new Uri("/responses", UriKind.Relative), + JsonContent($$"""{ "input": "second turn", "previous_response_id": "{{responseId}}" }""")); + + // Assert + Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode); + Assert.Equal(2, recorder.CallHistory.Count); + + // The second call must include the first turn's user message (continuity through HostedAgentState), + // whereas the first call must not contain the second turn's text. + string firstCallText = string.Join("\n", recorder.CallHistory[0].Select(m => m.Text)); + string secondCallText = string.Join("\n", recorder.CallHistory[1].Select(m => m.Text)); + Assert.Contains("first turn", secondCallText, StringComparison.Ordinal); + Assert.DoesNotContain("second turn", firstCallText, StringComparison.Ordinal); + } + + [Fact] + public async Task AgentRoute_MalformedBody_ReturnsBadRequestAsync() + { + // Arrange + HttpClient client = await this.StartAgentHostAsync(new TestHelpers.ConversationMemoryMockChatClient("ok")); + + // Act (missing the required "input" field) + HttpResponseMessage response = await client.PostAsync( + new Uri("/responses", UriKind.Relative), + JsonContent("""{ "model": "x" }""")); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task WorkflowRoute_RunThenResume_AdvancesCheckpointAcrossTurnsAsync() + { + // Arrange: a two-agent sequential workflow behind an app-owned route with checkpoint resume keyed by + // the stable conversation id. + HttpClient client = await this.StartWorkflowHostAsync(); + const string ConversationId = "conv_it_1"; + + // Act: first turn runs the workflow forward; second turn (same conversation) resumes from the checkpoint. + HttpResponseMessage firstResponse = await client.PostAsync( + new Uri("/responses", UriKind.Relative), + JsonContent($$"""{ "input": "draft this", "conversation": "{{ConversationId}}" }""")); + HttpResponseMessage secondResponse = await client.PostAsync( + new Uri("/responses", UriKind.Relative), + JsonContent($$"""{ "input": "and again", "conversation": "{{ConversationId}}" }""")); + + // Assert: both turns succeed and produce Responses-shaped payloads. + Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode); + using JsonDocument doc = JsonDocument.Parse(await secondResponse.Content.ReadAsStringAsync()); + Assert.StartsWith("resp_", doc.RootElement.GetProperty("id").GetString()); + Assert.Equal(ConversationId, doc.RootElement.GetProperty("conversation").GetProperty("id").GetString()); + } + + private async Task StartAgentHostAsync(IChatClient chatClient) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + this._app = builder.Build(); + + var agent = new ChatClientAgent(chatClient, instructions: "You are a helpful assistant.", name: "assistant"); + var state = new HostedAgentState(agent); + + this._app.MapPost("/responses", async (JsonElement body, HttpContext http, CancellationToken ct) => + { + OpenAIResponsesRunRequest run; + try + { + run = OpenAIResponses.ToAgentRunRequest(body); + } + catch (ArgumentException) + { + return Results.BadRequest(); + } + + string sessionId = OpenAIResponses.GetSessionId(body) ?? OpenAIResponses.CreateResponseId(); + AgentSession session = await state.GetOrCreateSessionAsync(sessionId, ct); + string responseId = OpenAIResponses.CreateResponseId(); + + if (body.TryGetProperty("stream", out JsonElement s) && s.ValueKind == JsonValueKind.True) + { + http.Response.ContentType = "text/event-stream"; + var updates = agent.RunStreamingAsync(run.Messages, session, run.Options, ct); + await foreach (string frame in OpenAIResponses.WriteResponseStreamAsync(updates, responseId, responseId, ct)) + { + await http.Response.WriteAsync(frame, ct); + } + + await state.SaveSessionAsync(responseId, session, ct); + return Results.Empty; + } + + AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options, ct); + await state.SaveSessionAsync(responseId, session, ct); + return Results.Json(OpenAIResponses.WriteResponse(result, responseId, responseId)); + }); + + await this._app.StartAsync(); + this._client = this.ResolveTestClient(); + return this._client; + } + + private async Task StartWorkflowHostAsync() + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + this._app = builder.Build(); + + AIAgent writer = new ChatClientAgent(new TestHelpers.ConversationMemoryMockChatClient("draft"), name: "Writer"); + AIAgent reviewer = new ChatClientAgent(new TestHelpers.ConversationMemoryMockChatClient("final"), name: "Reviewer"); + Workflow workflow = AgentWorkflowBuilder.BuildSequential(workflowName: "WriteAndReview", agents: [writer, reviewer]); + var state = new HostedWorkflowState(workflow); + + this._app.MapPost("/responses", async (JsonElement body, CancellationToken ct) => + { + OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); + string sessionId = GetConversationId(body) ?? OpenAIResponses.CreateResponseId(); + + HostedWorkflowRunResult result = await state.RunOrResumeAsync(sessionId, run.Messages.ToList(), ct); + + var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, $"{result.Events.Count} event(s)")); + return Results.Json(OpenAIResponses.WriteResponse(response, OpenAIResponses.CreateResponseId(), sessionId)); + }); + + await this._app.StartAsync(); + this._client = this.ResolveTestClient(); + return this._client; + } + + private HttpClient ResolveTestClient() + { + TestServer server = this._app!.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found"); + return server.CreateClient(); + } + + private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json"); + + private static string? GetConversationId(JsonElement body) + { + if (!body.TryGetProperty("conversation", out JsonElement conversation)) + { + return null; + } + + return conversation.ValueKind switch + { + JsonValueKind.String => conversation.GetString(), + JsonValueKind.Object when conversation.TryGetProperty("id", out JsonElement id) => id.GetString(), + _ => null, + }; + } + + public async ValueTask DisposeAsync() + { + this._client?.Dispose(); + if (this._app is not null) + { + await this._app.DisposeAsync(); + } + + GC.SuppressFinalize(this); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentStateTests.cs index cec96a828bd..2adccb2a145 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentStateTests.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Moq; +using Moq.Protected; namespace Microsoft.Agents.AI.Hosting.UnitTests; @@ -22,13 +23,20 @@ public void Constructor_NullAgent_Throws() => Assert.Throws("agent", () => new HostedAgentState(null!)); [Fact] - public void Constructor_NullStore_UsesInMemoryStore() + public async Task Constructor_NullStore_UsesInMemoryStoreAsync() { - // Act + // Arrange: a null store must fall back to an in-memory store, whose create-on-miss path calls the agent. + this._agentMock + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .Returns(new ValueTask(this._session)); var state = new HostedAgentState(this._agentMock.Object); + // Act + var session = await state.GetOrCreateSessionAsync("session-1"); + // Assert - Assert.Same(this._agentMock.Object, state.Agent); + Assert.Same(this._session, session); } [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs index 3fc36bf76c8..781888c3fbe 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs @@ -23,19 +23,6 @@ public void Constructor_NullWorkflow_Throws() => // Act & Assert Assert.Throws("workflow", () => new HostedWorkflowState(null!)); - [Fact] - public void Workflow_ReturnsProvidedWorkflow() - { - // Arrange - var workflow = CreateTestWorkflow(); - - // Act - var state = new HostedWorkflowState(workflow); - - // Assert - Assert.Same(workflow, state.Workflow); - } - [Fact] public void TryGetCheckpoint_UnknownSession_ReturnsFalse() { @@ -305,8 +292,8 @@ public async Task RunOrResumeAsync_AdaptsResponsesInputToTypedStartExecutorAsync var state = new HostedWorkflowState(BriefWorkflow.Build()); // Simulate parsing a structured Responses text payload into the start executor's input type. - const string responsesText = "{\"topic\":\"electric SUV\",\"style\":\"playful\"}"; - using JsonDocument doc = JsonDocument.Parse(responsesText); + const string ResponsesText = "{\"topic\":\"electric SUV\",\"style\":\"playful\"}"; + using JsonDocument doc = JsonDocument.Parse(ResponsesText); var brief = new BriefWorkflow.WriterBrief( doc.RootElement.GetProperty("topic").GetString()!, doc.RootElement.GetProperty("style").GetString()!); From bca52a2a559dc389ff7e9dd045596709dba928dc Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:45:22 +0100 Subject: [PATCH 17/28] Document commit-order contract for ICheckpointStore.RetrieveIndexAsync --- .../Microsoft.Agents.AI.Workflows/CheckpointManager.cs | 2 ++ .../Checkpointing/ICheckpointStore.cs | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/CheckpointManager.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/CheckpointManager.cs index 5ff7b84437d..efaf4f8abd7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/CheckpointManager.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/CheckpointManager.cs @@ -72,6 +72,8 @@ ValueTask> ICheckpointManager.RetrieveIndexAsync(str /// public async ValueTask GetLatestCheckpointAsync(string sessionId, CancellationToken cancellationToken = default) { + // ICheckpointStore.RetrieveIndexAsync is contractually required to return checkpoints in commit order + // (oldest first, most recently committed last), so the last enumerated entry is the latest checkpoint. IEnumerable index = await this._impl.RetrieveIndexAsync(sessionId, withParent: null).ConfigureAwait(false); CheckpointInfo? latest = null; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/ICheckpointStore.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/ICheckpointStore.cs index af2fa5423e1..b4a65b70abc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/ICheckpointStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/ICheckpointStore.cs @@ -19,8 +19,14 @@ public interface ICheckpointStore /// An optional parent checkpoint to filter the results. If specified, only checkpoints with the given parent are /// returned; otherwise, all checkpoints for the session are included. /// A value task representing the asynchronous operation. The result contains a collection of objects associated with the specified session. The collection is empty if no checkpoints are - /// found. + /// cref="CheckpointInfo"/> objects associated with the specified session, ordered by commit time from the oldest to the + /// most recently committed. The collection is empty if no checkpoints are found. + /// + /// Implementations must return checkpoints in the order they were committed, with the most recently committed checkpoint + /// last. This ordering is a contract of the store: callers such as rely on it to identify + /// the latest checkpoint for a session, so a store that returns checkpoints unordered (or newest-first) will cause the + /// wrong checkpoint to be resumed. + /// ValueTask> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null); /// From 8da4fcfc34c236188f95f5d0996320889c7326dd Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:02:36 +0100 Subject: [PATCH 18/28] Restructure hosting samples under af-hosting with client/server split matching Python parity --- dotnet/agent-framework-dotnet.slnx | 16 +- .../HostingResponsesAgent.csproj | 22 -- .../HostingResponsesAgent/README.md | 44 ---- .../HostingResponsesWorkflow.csproj | 23 -- .../HostingResponsesWorkflow/Program.cs | 143 ------------- .../HostingResponsesWorkflow/README.md | 63 ------ .../samples/04-hosting/af-hosting/README.md | 38 ++++ .../local_responses/Client/Client.csproj | 19 ++ .../local_responses/Client/Program.cs | 79 +++++++ .../local_responses/Client/README.md | 20 ++ .../af-hosting/local_responses/README.md | 57 +++++ .../local_responses/Server}/Program.cs | 27 ++- .../local_responses/Server/README.md | 30 +++ .../local_responses/Server/Server.csproj | 20 ++ .../Client/Client.csproj | 19 ++ .../Client/Program.cs | 69 ++++++ .../local_responses_workflow/Client/README.md | 20 ++ .../local_responses_workflow/README.md | 64 ++++++ .../Server/Program.cs | 196 ++++++++++++++++++ .../local_responses_workflow/Server/README.md | 31 +++ .../Server/Server.csproj | 21 ++ 21 files changed, 721 insertions(+), 300 deletions(-) delete mode 100644 dotnet/samples/04-hosting/HostingResponsesAgent/HostingResponsesAgent.csproj delete mode 100644 dotnet/samples/04-hosting/HostingResponsesAgent/README.md delete mode 100644 dotnet/samples/04-hosting/HostingResponsesWorkflow/HostingResponsesWorkflow.csproj delete mode 100644 dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs delete mode 100644 dotnet/samples/04-hosting/HostingResponsesWorkflow/README.md create mode 100644 dotnet/samples/04-hosting/af-hosting/README.md create mode 100644 dotnet/samples/04-hosting/af-hosting/local_responses/Client/Client.csproj create mode 100644 dotnet/samples/04-hosting/af-hosting/local_responses/Client/Program.cs create mode 100644 dotnet/samples/04-hosting/af-hosting/local_responses/Client/README.md create mode 100644 dotnet/samples/04-hosting/af-hosting/local_responses/README.md rename dotnet/samples/04-hosting/{HostingResponsesAgent => af-hosting/local_responses/Server}/Program.cs (77%) create mode 100644 dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md create mode 100644 dotnet/samples/04-hosting/af-hosting/local_responses/Server/Server.csproj create mode 100644 dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Client/Client.csproj create mode 100644 dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Client/Program.cs create mode 100644 dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Client/README.md create mode 100644 dotnet/samples/04-hosting/af-hosting/local_responses_workflow/README.md create mode 100644 dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/Program.cs create mode 100644 dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/README.md create mode 100644 dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/Server.csproj diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index f99c0395fe0..e639de13e63 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -313,9 +313,19 @@ - - - + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/HostingResponsesAgent/HostingResponsesAgent.csproj b/dotnet/samples/04-hosting/HostingResponsesAgent/HostingResponsesAgent.csproj deleted file mode 100644 index 28fc17bb746..00000000000 --- a/dotnet/samples/04-hosting/HostingResponsesAgent/HostingResponsesAgent.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net10.0 - enable - enable - HostingResponsesAgent - HostingResponsesAgent - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/HostingResponsesAgent/README.md b/dotnet/samples/04-hosting/HostingResponsesAgent/README.md deleted file mode 100644 index c513c424867..00000000000 --- a/dotnet/samples/04-hosting/HostingResponsesAgent/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# Hosting Responses Agent (app-owned routing) - -Shows how an application can own its own ASP.NET Core route and expose an `AIAgent` over the OpenAI -Responses protocol by calling the Agent Framework `OpenAIResponses` conversion helpers, instead of using -the batteries-included `MapOpenAIResponses` server. - -The application owns routing, authentication, and session storage. The framework provides only the -protocol conversion: - -- `OpenAIResponses.GetSessionId(body)` extracts the untrusted continuation-id candidate. -- `OpenAIResponses.ToAgentRunRequest(body)` parses the request into messages + run options. -- `OpenAIResponses.WriteResponse(...)` / `WriteResponseStreamAsync(...)` render the agent output back to - the Responses wire shape (non-streaming JSON and SSE). - -Session continuity uses `HostedAgentState` over an in-memory `AgentSessionStore`. - -## Run - -```bash -export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" -export FOUNDRY_MODEL="gpt-5.4-mini" # optional, defaults to gpt-5.4-mini -dotnet run -``` - -Call the endpoint (non-streaming): - -```bash -curl -s http://localhost:5000/responses -H "content-type: application/json" \ - -d '{ "input": "Write a haiku about the sea." }' -``` - -Streaming (SSE): - -```bash -curl -N http://localhost:5000/responses -H "content-type: application/json" \ - -d '{ "input": "Write a haiku about the sea.", "stream": true }' -``` - -## Security note - -`GetSessionId(...)` returns an untrusted candidate key. This sample'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`. diff --git a/dotnet/samples/04-hosting/HostingResponsesWorkflow/HostingResponsesWorkflow.csproj b/dotnet/samples/04-hosting/HostingResponsesWorkflow/HostingResponsesWorkflow.csproj deleted file mode 100644 index fc53462cf19..00000000000 --- a/dotnet/samples/04-hosting/HostingResponsesWorkflow/HostingResponsesWorkflow.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - net10.0 - enable - enable - HostingResponsesWorkflow - HostingResponsesWorkflow - - - - - - - - - - - - - - - diff --git a/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs b/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs deleted file mode 100644 index 77ddc1ed2a1..00000000000 --- a/dotnet/samples/04-hosting/HostingResponsesWorkflow/Program.cs +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample shows how an application can own its own ASP.NET Core route and expose a workflow over the -// OpenAI Responses protocol. It uses the OpenAIResponses conversion helpers for the wire protocol and -// HostedWorkflowState for per-session checkpoint resume. The application keeps control of routing, auth, -// and checkpoint storage. - -using System.Text.Json; -using Azure.AI.Projects; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Hosting; -using Microsoft.Agents.AI.Hosting.OpenAI; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Extensions.AI; - -var builder = WebApplication.CreateBuilder(args); - -// Configuration via environment variables (never hardcode secrets). -string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); -string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; - -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -var projectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()); -AIAgent writer = projectClient.AsAIAgent(model: model, instructions: "Write a concise first draft for the user's request.", name: "Writer"); -AIAgent reviewer = projectClient.AsAIAgent(model: model, instructions: "Improve the draft and produce the final answer.", name: "Reviewer"); - -Workflow workflow = AgentWorkflowBuilder.BuildSequential(workflowName: "WriteAndReview", agents: [writer, reviewer]); - -// The last agent in the sequential pipeline produces the workflow's final answer. The streaming path filters -// updates to this agent so the streamed response matches the non-streaming final-message response. -string finalAgentName = reviewer.Name ?? "Reviewer"; - -// Optional shared execution state: pairs the workflow with an in-memory CheckpointManager and a per-session -// sessionId -> CheckpointInfo head cursor so a session can resume from its last checkpoint. -var state = new HostedWorkflowState(workflow); - -var app = builder.Build(); - -// The application owns this route. Binding the body as JsonElement lets ASP.NET Core deserialize the JSON -// request body directly, so there is no JsonDocument to own or dispose. -app.MapPost("/responses", async (JsonElement body, HttpContext http, CancellationToken cancellationToken) => -{ - // Workflow checkpoint resume needs a STABLE key across turns. previous_response_id changes every turn, - // so key on the conversation id (constant for the conversation). The candidate is untrusted: a real app - // authenticates the caller and authorizes/binds this key before using it. This sample falls back to a fresh id. - string sessionId = GetConversationId(body) ?? OpenAIResponses.CreateResponseId(); - - OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); - var messages = run.Messages.ToList(); - - bool stream = body.TryGetProperty("stream", out JsonElement streamProp) && streamProp.ValueKind == JsonValueKind.True; - - if (stream) - { - // Stream the workflow's agent updates back over the Responses SSE wire. Runs forward on the first - // call for this session, or restores the session's latest checkpoint and runs forward with this - // turn's input thereafter, recording the new head checkpoint once the stream completes. - http.Response.ContentType = "text/event-stream"; - string streamResponseId = OpenAIResponses.CreateResponseId(); - IAsyncEnumerable updates = ExtractUpdates(state.RunOrResumeStreamingAsync(sessionId, messages, cancellationToken), finalAgentName, cancellationToken); - - await foreach (string frame in OpenAIResponses.WriteResponseStreamAsync(updates, streamResponseId, sessionId, cancellationToken).ConfigureAwait(false)) - { - await http.Response.WriteAsync(frame, cancellationToken).ConfigureAwait(false); - await http.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false); - } - - return Results.Empty; - } - - // Runs the workflow forward on the first call for this session, or restores the session's latest - // checkpoint and runs forward with this turn's input thereafter, then records the new head checkpoint. - HostedWorkflowRunResult result = await state.RunOrResumeAsync(sessionId, messages, cancellationToken).ConfigureAwait(false); - - // Render the workflow's output. Applications extract output from the emitted events per their - // workflow's design; here we surface the final assistant message the pipeline produced this turn. - AgentResponse response = BuildWorkflowResponse(result); - return Results.Json(OpenAIResponses.WriteResponse(response, OpenAIResponses.CreateResponseId(), sessionId)); -}); - -app.Run(); - -// Projects the final agent's streaming updates from the emitted workflow events, so the streamed response -// matches the non-streaming final-message response rather than also streaming intermediate agents' drafts. -static async IAsyncEnumerable ExtractUpdates( - IAsyncEnumerable events, - string finalAgentName, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) -{ - await foreach (WorkflowEvent evt in events.WithCancellation(cancellationToken).ConfigureAwait(false)) - { - if (evt is AgentResponseUpdateEvent updateEvent && - string.Equals(updateEvent.Update.AuthorName, finalAgentName, StringComparison.Ordinal)) - { - yield return updateEvent.Update; - } - } -} - -// Extracts the final assistant message produced by the workflow this turn from its output events, -// falling back to a short run summary when the workflow emitted no message output. -static AgentResponse BuildWorkflowResponse(HostedWorkflowRunResult result) -{ - ChatMessage? finalMessage = null; - foreach (WorkflowEvent evt in result.Events) - { - if (evt is WorkflowOutputEvent output && output.Data is IEnumerable messages) - { - foreach (ChatMessage message in messages) - { - if (message.Role == ChatRole.Assistant && !string.IsNullOrWhiteSpace(message.Text)) - { - finalMessage = message; - } - } - } - } - - return finalMessage is not null - ? new AgentResponse(finalMessage) - : new AgentResponse(new ChatMessage(ChatRole.Assistant, $"{result.Events.Count} workflow event(s) processed.")); -} - -// Reads the stable conversation id (string or object form) from the request body. Unlike previous_response_id, -// the conversation id is constant across turns, so it is a valid workflow checkpoint session key. -static string? GetConversationId(JsonElement body) -{ - if (!body.TryGetProperty("conversation", out JsonElement conversation)) - { - return null; - } - - return conversation.ValueKind switch - { - JsonValueKind.String => conversation.GetString(), - JsonValueKind.Object when conversation.TryGetProperty("id", out JsonElement id) => id.GetString(), - _ => null, - }; -} diff --git a/dotnet/samples/04-hosting/HostingResponsesWorkflow/README.md b/dotnet/samples/04-hosting/HostingResponsesWorkflow/README.md deleted file mode 100644 index a7031bc27b7..00000000000 --- a/dotnet/samples/04-hosting/HostingResponsesWorkflow/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# Hosting Responses Workflow (app-owned routing + checkpoint resume) - -Shows how an application can own its own ASP.NET Core route and expose a workflow over the OpenAI -Responses protocol, using the `OpenAIResponses` conversion helpers for the wire protocol and -`HostedWorkflowState` for per-session checkpoint resume. - -The application owns routing, authentication, and checkpoint storage. `HostedWorkflowState` pairs the -workflow with a `CheckpointManager` (in-memory by default) and a per-session `sessionId -> CheckpointInfo` -head cursor. `RunOrResumeAsync(sessionId, input)` runs the workflow forward on the first call for a -session; on later calls it restores the session's latest checkpoint to rehydrate accumulated state and -then runs the workflow forward with the **new turn's input** (for agent workflows a `TurnToken` drives -the turn). This mirrors the Python hosting host's restore-then-run semantics. - -> The .NET workflow checkpoint store is already keyed by session id, but `CheckpointInfo` carries no -> ordering, which is why the holder remembers the head checkpoint per session. - -## Run - -```bash -export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" -export FOUNDRY_MODEL="gpt-5.4-mini" # optional, defaults to gpt-5.4-mini -dotnet run -``` - -First turn — start a conversation: - -```bash -curl -s http://localhost:5000/responses -H "content-type: application/json" \ - -d '{ "input": "Draft a short product announcement for a reusable coffee mug.", "conversation": "conv-1" }' -``` - -Follow-up turn — resume with new input using the **same** `conversation` id: - -```bash -curl -s http://localhost:5000/responses -H "content-type: application/json" \ - -d '{ "input": "Rewrite it as a single punchy tagline.", "conversation": "conv-1" }' -``` - -The second turn restores the first turn's checkpoint and applies the new instruction, so the answer -builds on the earlier draft. Use a **stable** key: `conversation` stays constant across turns, whereas -`previous_response_id` changes every turn and is not a valid checkpoint key. - -Streaming (SSE) — add `"stream": true` to stream the workflow's agent updates back over the Responses -Server-Sent-Events wire (works for both the first turn and resumed turns): - -```bash -curl -N http://localhost:5000/responses -H "content-type: application/json" \ - -d '{ "input": "Draft a short slogan for a coffee mug.", "conversation": "conv-1", "stream": true }' -``` - -## Notes - -- The sample renders the workflow's final assistant message from the emitted `WorkflowEvent`s; real - applications extract output per their own workflow's design. -- The default in-memory cursor does not survive process restarts. Durable or multi-replica hosts should - supply a durable `CheckpointManager` and record `HostedWorkflowRunResult.Checkpoint` in their own - durable cursor. - -## Security note - -`GetSessionId(...)` returns an untrusted candidate key. Authenticate the caller and authorize/bind the id -before using it as the workflow's checkpoint session id. The checkpoint boundary must be at least as -specific as the authorized session boundary. diff --git a/dotnet/samples/04-hosting/af-hosting/README.md b/dotnet/samples/04-hosting/af-hosting/README.md new file mode 100644 index 00000000000..2a2f7845913 --- /dev/null +++ b/dotnet/samples/04-hosting/af-hosting/README.md @@ -0,0 +1,38 @@ +# Agent Framework hosting helper samples + +End-to-end samples for exposing Agent Framework targets through app-owned hosting routes. + +The helper-first hosting packages provide protocol conversion and optional execution state. The +application still owns the web framework, native SDK clients, authentication, response construction, and +deployment shape. + +| Sample | What it shows | +|---|---| +| [`local_responses/`](./local_responses) | One agent behind an app-owned ASP.NET Core route using the `OpenAIResponses` helper functions plus `HostedAgentState` / `AgentSessionStore`. Start here to learn the helper seam. | +| [`local_responses_workflow/`](./local_responses_workflow) | A workflow target behind an app-owned ASP.NET Core route using the `OpenAIResponses` helper functions, `HostedWorkflowState`, an explicit `CheckpointManager`, and an app-owned checkpoint cursor. | + +Each sample is a **client/server pair**. Unlike the Python samples (which run a server `app.py` and calling +scripts as loose files), .NET samples are projects, so each pair is split into two projects: + +``` +local_responses/ +├── Server/ # exposes POST /responses using the OpenAIResponses helpers +└── Client/ # consumes it two ways: CC (IChatClient) and MAF (AIAgent) +``` + +The `Client` shows the two idiomatic ways to consume the endpoint from .NET, both against the same server: + +- **CC** — a plain `Microsoft.Extensions.AI.IChatClient` (the lower-level chat-client path). +- **MAF** — a Microsoft Agent Framework `AIAgent` (the higher-level agent path). + +## Relationship to `../foundry-hosted-agents/` + +The sibling [`../FoundryHostedAgents/`](../FoundryHostedAgents) directory contains samples for agents that +run inside the Foundry Hosted Agents platform. Those samples use the Foundry-managed protocol surface with +no app-owned hosting route involved. + +| Aspect | `af-hosting/` (this directory) | `FoundryHostedAgents/` | +|---|---|---| +| Server stack | App-owned ASP.NET Core + hosting protocol helpers | Foundry Hosted Agents runtime | +| Protocol surface | The app exposes the route and calls helpers | The platform exposes Responses + Invocations | +| When to pick this | You need custom hosting code or want to learn the helper seam | You want the Foundry-managed hosting surface | diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Client/Client.csproj b/dotnet/samples/04-hosting/af-hosting/local_responses/Client/Client.csproj new file mode 100644 index 00000000000..966bcba0de8 --- /dev/null +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Client/Client.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Client/Program.cs b/dotnet/samples/04-hosting/af-hosting/local_responses/Client/Program.cs new file mode 100644 index 00000000000..8c92187d3ce --- /dev/null +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Client/Program.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Client for the HostingResponsesAgent server sample. It shows the two idiomatic ways to consume an +// OpenAI Responses endpoint from .NET, both pointed at the same app-owned server route: +// +// 1. CC - a plain Microsoft.Extensions.AI IChatClient (the lower-level chat-client path). +// 2. MAF - a Microsoft Agent Framework AIAgent + AgentSession (the higher-level agent path). +// +// Both run the same three-turn conversation. The third turn only makes sense if the server remembered +// the first turn, so it also proves multi-turn session continuity across the rotating response-id chain. + +using System.ClientModel; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI; +using OpenAI.Responses; + +string serverUrl = Environment.GetEnvironmentVariable("RESPONSES_SERVER_URL") ?? "http://localhost:5000"; + +// The server ignores the model id (it runs its own configured agent), but the OpenAI SDK requires one to +// shape the request. Reuse FOUNDRY_MODEL for parity with the server sample. +string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; + +string[] prompts = +[ + "What is the weather in Tokyo?", + "And what about Amsterdam?", + "Which of the two cities we just discussed is warmer?", +]; + +// A single ResponsesClient pointed at the local server backs both consumption paths. The api key is unused +// by the sample server, but the SDK requires a credential. +ResponsesClient responseClient = new OpenAIClient( + new ApiKeyCredential("not-needed"), + new OpenAIClientOptions { Endpoint = new Uri(serverUrl) }) + .GetResponsesClient(); + +Console.WriteLine($"Connecting to {serverUrl}\n"); + +await RunWithChatClientAsync(responseClient, model, prompts).ConfigureAwait(false); +await RunWithAgentAsync(responseClient, model, prompts).ConfigureAwait(false); + +// CC path: consume the endpoint through a Microsoft.Extensions.AI IChatClient. Continuity is threaded by +// hand: each response carries the server's response id as ChatResponse.ConversationId, which we pass back +// as the next turn's ChatOptions.ConversationId. Because it is a "resp_" id, the SDK sends it as +// previous_response_id, exactly what the server's GetSessionId reads. +static async Task RunWithChatClientAsync(ResponsesClient responseClient, string model, string[] prompts) +{ + Console.WriteLine("== CC: Microsoft.Extensions.AI IChatClient =="); + IChatClient chatClient = responseClient.AsIChatClient(model); + + string? previousResponseId = null; + foreach (string prompt in prompts) + { + Console.WriteLine($"User: {prompt}"); + ChatResponse response = await chatClient.GetResponseAsync( + prompt, + new ChatOptions { ConversationId = previousResponseId }).ConfigureAwait(false); + Console.WriteLine($"Agent: {response.Text}"); + previousResponseId = response.ConversationId; + Console.WriteLine($"Response ID: {previousResponseId}\n"); + } +} + +// MAF path: consume the same endpoint through an Agent Framework AIAgent. A single AgentSession threads the +// rotating response-id chain automatically, so the caller only sends the new user message each turn. +static async Task RunWithAgentAsync(ResponsesClient responseClient, string model, string[] prompts) +{ + Console.WriteLine("== MAF: Agent Framework AIAgent + AgentSession =="); + AIAgent agent = responseClient.AsAIAgent(model: model, name: "HostedResponsesClient"); + AgentSession session = await agent.CreateSessionAsync().ConfigureAwait(false); + + foreach (string prompt in prompts) + { + Console.WriteLine($"User: {prompt}"); + AgentResponse response = await agent.RunAsync(prompt, session).ConfigureAwait(false); + Console.WriteLine($"Agent: {response.Text}\n"); + } +} diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Client/README.md b/dotnet/samples/04-hosting/af-hosting/local_responses/Client/README.md new file mode 100644 index 00000000000..27c130017e6 --- /dev/null +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Client/README.md @@ -0,0 +1,20 @@ +# Client (Hosting Responses Agent) + +Client half of the [Hosting Responses Agent](../README.md) sample. + +Runs the same three-turn conversation twice against the server's `POST /responses` route, once per +consumption path: + +- **CC** — a plain `Microsoft.Extensions.AI.IChatClient` from `ResponsesClient.AsIChatClient(model)`. + Continuity is threaded by hand: each response's `ChatResponse.ConversationId` (a `resp_` id) is passed + back as the next turn's `ChatOptions.ConversationId`, which the SDK sends as `previous_response_id`. +- **MAF** — a Microsoft Agent Framework `AIAgent` from `ResponsesClient.AsAIAgent(...)`. A single + `AgentSession` threads the rotating response-id chain automatically. + +The third turn asks about the first turn, so a correct answer proves multi-turn session continuity. + +```bash +dotnet run +``` + +Defaults to `http://localhost:5000`; override with `RESPONSES_SERVER_URL`. Start the server first. diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/README.md b/dotnet/samples/04-hosting/af-hosting/local_responses/README.md new file mode 100644 index 00000000000..eb3385229fb --- /dev/null +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/README.md @@ -0,0 +1,57 @@ +# Hosting Responses Agent (client / server) + +A client/server pair showing how to expose an `AIAgent` over the OpenAI Responses protocol from an +app-owned ASP.NET Core route, and how to consume it from .NET two different ways. + +``` +local_responses/ +├── Server/ # exposes POST /responses using the OpenAIResponses helpers +└── Client/ # consumes it two ways: CC (IChatClient) and MAF (AIAgent) +``` + +## Server + +The server owns routing, authentication, and session storage. The framework provides only the protocol +conversion via `OpenAIResponses` (`GetSessionId`, `ToAgentRunRequest`, `WriteResponse` / +`WriteResponseStreamAsync`), instead of the batteries-included `MapOpenAIResponses` server. The agent has a +deterministic `lookup_weather` tool (mirroring the Python sample). Session continuity uses `HostedAgentState` +over an in-memory `AgentSessionStore`. It binds to `http://localhost:5000`. + +See [Server/README.md](Server/README.md). + +## Client + +A single program that runs the same three-turn conversation twice, once per consumption path: + +- **CC** — a plain `Microsoft.Extensions.AI.IChatClient` (the lower-level chat-client path). +- **MAF** — a Microsoft Agent Framework `AIAgent` + `AgentSession` (the higher-level agent path). + +Both point at the same server. The third turn asks about the first turn, proving multi-turn session +continuity across the rotating response-id chain. + +See [Client/README.md](Client/README.md). + +## Run + +Start the server in one shell: + +```bash +export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +export FOUNDRY_MODEL="gpt-5.4-mini" # optional, defaults to gpt-5.4-mini +dotnet run --project Server +``` + +Then run the client in another shell: + +```bash +dotnet run --project Client +``` + +The client defaults to `http://localhost:5000`; override with `RESPONSES_SERVER_URL`. + +## Security note + +`OpenAIResponses.GetSessionId(...)` 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`. diff --git a/dotnet/samples/04-hosting/HostingResponsesAgent/Program.cs b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs similarity index 77% rename from dotnet/samples/04-hosting/HostingResponsesAgent/Program.cs rename to dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs index 8236ed203ec..899b49f2bbf 100644 --- a/dotnet/samples/04-hosting/HostingResponsesAgent/Program.cs +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs @@ -5,12 +5,14 @@ // using the batteries-included MapOpenAIResponses server. The application keeps control of routing, auth, // and session storage; the helpers provide only the protocol <-> agent conversion. +using System.ComponentModel; using System.Text.Json; using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Hosting.OpenAI; +using Microsoft.Extensions.AI; var builder = WebApplication.CreateBuilder(args); @@ -19,11 +21,30 @@ ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; +// A deterministic weather tool (mirrors the Python sample's lookup_weather @tool). +[Description("Return a deterministic weather report for a city.")] +static string LookupWeather([Description("The city to look up weather for.")] string location) +{ + int highTemp = 5 + (System.Text.Encoding.UTF8.GetBytes(location).Sum(b => b) % 21); + return location switch + { + "Seattle" => $"Seattle is rainy with a high of {highTemp}°C.", + "Amsterdam" => $"Amsterdam is cloudy with a high of {highTemp}°C.", + "Tokyo" => $"Tokyo is clear with a high of {highTemp}°C.", + _ => $"{location} is sunny with a high of {highTemp}°C.", + }; +} + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()) - .AsAIAgent(model: model, instructions: "You are a helpful assistant.", name: "Assistant"); + .AsAIAgent( + model: model, + instructions: "You are a friendly weather assistant. Use the lookup_weather tool for any weather " + + "question and answer in one short sentence.", + name: "WeatherAgent", + tools: [AIFunctionFactory.Create(LookupWeather, name: "lookup_weather")]); // Optional shared execution state: pairs the agent with a session store (in-memory by default). var state = new HostedAgentState(agent); @@ -69,7 +90,9 @@ return Results.Json(OpenAIResponses.WriteResponse(result, responseId, responseId)); }); -app.Run(); +// Bind to a fixed local URL so the paired client sample has a deterministic default. +// Override with the ASPNETCORE_URLS environment variable when needed. +app.Run("http://localhost:5000"); // Application-owned trust decision. Replace with real authentication + authorization: verify the caller, // then authorize/bind the candidate id to the authenticated principal before returning it. diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md new file mode 100644 index 00000000000..66e0e9a1499 --- /dev/null +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md @@ -0,0 +1,30 @@ +# Server (Hosting Responses Agent) + +Server half of the [Hosting Responses Agent](../README.md) sample. + +Exposes an `AIAgent` over the OpenAI Responses protocol on an app-owned `POST /responses` route: + +- `OpenAIResponses.GetSessionId(body)` extracts the untrusted continuation-id candidate. +- `OpenAIResponses.ToAgentRunRequest(body)` parses the request into messages + run options. +- `OpenAIResponses.WriteResponse(...)` / `WriteResponseStreamAsync(...)` render the agent output back to the + Responses wire shape (non-streaming JSON and SSE). + +Session continuity uses `HostedAgentState` over an in-memory `AgentSessionStore`. The agent has a +deterministic `lookup_weather` tool (mirroring the Python sample). Binds to `http://localhost:5000` (override +with `ASPNETCORE_URLS`). + +```bash +export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +export FOUNDRY_MODEL="gpt-5.4-mini" +dotnet run +``` + +You can also call it directly with curl: + +```bash +curl -s http://localhost:5000/responses -H "content-type: application/json" \ + -d '{ "input": "What is the weather in Tokyo?" }' + +curl -N http://localhost:5000/responses -H "content-type: application/json" \ + -d '{ "input": "What is the weather in Tokyo?", "stream": true }' +``` diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Server.csproj b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Server.csproj new file mode 100644 index 00000000000..232aede78d2 --- /dev/null +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Server.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Client/Client.csproj b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Client/Client.csproj new file mode 100644 index 00000000000..966bcba0de8 --- /dev/null +++ b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Client/Client.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Client/Program.cs b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Client/Program.cs new file mode 100644 index 00000000000..29b9852669a --- /dev/null +++ b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Client/Program.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Client for the local_responses_workflow server sample. Like the agent client, it shows the two idiomatic +// ways to consume an OpenAI Responses endpoint from .NET, both pointed at the same app-owned workflow route: +// +// 1. CC - a plain Microsoft.Extensions.AI IChatClient (the lower-level chat-client path). +// 2. MAF - a Microsoft Agent Framework AIAgent + AgentSession (the higher-level agent path). +// +// The server implements previous_response_id continuation only (it rejects conversation-id continuity), so +// both paths follow the rotating response-id chain: the first turn sends a JSON brief, the follow-up turn +// continues from the first turn's response id. The workflow resumes its checkpoint across that chain. + +using System.ClientModel; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI; +using OpenAI.Responses; + +string serverUrl = Environment.GetEnvironmentVariable("RESPONSES_SERVER_URL") ?? "http://localhost:5001"; +string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; + +const string Brief = """{ "topic": "electric SUV", "style": "playful", "audience": "young families" }"""; +const string FollowUp = "Make it a little more premium, but still family friendly."; + +ResponsesClient responseClient = new OpenAIClient( + new ApiKeyCredential("not-needed"), + new OpenAIClientOptions { Endpoint = new Uri(serverUrl) }) + .GetResponsesClient(); + +Console.WriteLine($"Connecting to {serverUrl}\n"); + +await RunWithChatClientAsync(responseClient, model).ConfigureAwait(false); +await RunWithAgentAsync(responseClient, model).ConfigureAwait(false); + +// CC path: consume the endpoint through a Microsoft.Extensions.AI IChatClient. Continuity is threaded by +// hand: each response's ChatResponse.ConversationId (a "resp_" id) is passed back as the next turn's +// ChatOptions.ConversationId, which the SDK sends as previous_response_id. +static async Task RunWithChatClientAsync(ResponsesClient responseClient, string model) +{ + Console.WriteLine("== CC: Microsoft.Extensions.AI IChatClient =="); + IChatClient chatClient = responseClient.AsIChatClient(model); + + Console.WriteLine($"User: {Brief}"); + ChatResponse first = await chatClient.GetResponseAsync(Brief).ConfigureAwait(false); + Console.WriteLine($"Workflow: {first.Text}\n"); + + Console.WriteLine($"User: {FollowUp}"); + ChatResponse second = await chatClient.GetResponseAsync( + FollowUp, + new ChatOptions { ConversationId = first.ConversationId }).ConfigureAwait(false); + Console.WriteLine($"Workflow: {second.Text}\n"); +} + +// MAF path: consume the same endpoint through an Agent Framework AIAgent. A single AgentSession threads the +// rotating previous_response_id chain automatically, so the caller only sends the new input each turn. +static async Task RunWithAgentAsync(ResponsesClient responseClient, string model) +{ + Console.WriteLine("== MAF: Agent Framework AIAgent + AgentSession =="); + AIAgent agent = responseClient.AsAIAgent(model: model, name: "HostedWorkflowClient"); + AgentSession session = await agent.CreateSessionAsync().ConfigureAwait(false); + + Console.WriteLine($"User: {Brief}"); + AgentResponse first = await agent.RunAsync(Brief, session).ConfigureAwait(false); + Console.WriteLine($"Workflow: {first.Text}\n"); + + Console.WriteLine($"User: {FollowUp}"); + AgentResponse second = await agent.RunAsync(FollowUp, session).ConfigureAwait(false); + Console.WriteLine($"Workflow: {second.Text}\n"); +} diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Client/README.md b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Client/README.md new file mode 100644 index 00000000000..f4c9d66da0e --- /dev/null +++ b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Client/README.md @@ -0,0 +1,20 @@ +# Client (Hosting Responses Workflow) + +Client half of the [Hosting Responses Workflow](../README.md) sample. + +Runs the same two-turn conversation twice against the server's `POST /responses` route, once per consumption +path: + +- **CC** — a plain `Microsoft.Extensions.AI.IChatClient` from `ResponsesClient.AsIChatClient(model)`. +- **MAF** — a Microsoft Agent Framework `AIAgent` + `AgentSession` from `ResponsesClient.AsAIAgent(...)`. + +Both send a JSON brief on the first turn and a refinement on the second, following the rotating +`previous_response_id` chain (the CC path threads it by hand via `ChatOptions.ConversationId`; the MAF path +lets `AgentSession` do it). The follow-up only makes sense if the workflow resumed the first turn's +checkpoint, so it proves checkpoint continuity. + +```bash +dotnet run +``` + +Defaults to `http://localhost:5001`; override with `RESPONSES_SERVER_URL`. Start the server first. \ No newline at end of file diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/README.md b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/README.md new file mode 100644 index 00000000000..b6719528309 --- /dev/null +++ b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/README.md @@ -0,0 +1,64 @@ +# Hosting Responses Workflow (client / server) + +A client/server pair showing how to expose a **workflow** over the OpenAI Responses protocol from an +app-owned ASP.NET Core route with per-session checkpoint resume, and how to consume it from .NET two +different ways. This mirrors the Python `local_responses_workflow` sample. + +``` +local_responses_workflow/ +├── Server/ # exposes POST /responses; previous_response_id continuation with checkpoint resume +└── Client/ # consumes it two ways: CC (IChatClient) and MAF (AIAgent) +``` + +## Server + +The server owns routing, authentication, and checkpoint storage. It uses the `OpenAIResponses` conversion +helpers for the wire protocol and `HostedWorkflowState` for per-session checkpoint resume. The workflow is a +brief adapter, a slogan-writer agent, and a formatter that renders one slogan line. The first turn runs the +workflow forward; later turns restore the latest checkpoint and run forward with the new brief. It binds to +`http://localhost:5001`. + +Like the Python sample, the server supports **`previous_response_id` continuation only** and **rejects +`conversation` continuity with HTTP 400**. Because `previous_response_id` rotates every turn, the app owns a +cursor that maps each response id to the stable workflow session id (the .NET equivalent of the Python +sample's `CheckpointCursorStore`). + +See [Server/README.md](Server/README.md). + +## Client + +A single program that runs the same two-turn conversation twice, once per consumption path: + +- **CC** — a plain `Microsoft.Extensions.AI.IChatClient` (the lower-level chat-client path). +- **MAF** — a Microsoft Agent Framework `AIAgent` + `AgentSession` (the higher-level agent path). + +Both send a JSON brief on the first turn and a refinement on the second, following the rotating +`previous_response_id` chain (the CC path threads it by hand; the MAF path lets `AgentSession` do it). The +follow-up only makes sense if the workflow resumed the first turn's checkpoint. + +See [Client/README.md](Client/README.md). + +## Run + +Start the server in one shell: + +```bash +export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +export FOUNDRY_MODEL="gpt-5.4-mini" # optional, defaults to gpt-5.4-mini +dotnet run --project Server +``` + +Then run the client in another shell: + +```bash +dotnet run --project Client +``` + +The client defaults to `http://localhost:5001`; override with `RESPONSES_SERVER_URL`. + +## Why previous_response_id needs a cursor + +`previous_response_id` changes every turn, so it cannot key checkpoint storage directly. The app maps each +response id to the stable workflow session id, so every id in the rotating chain resumes the same +checkpointed run. Sending `conversation` is rejected with HTTP 400 to keep this sample focused on one +continuation mode. \ No newline at end of file diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/Program.cs b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/Program.cs new file mode 100644 index 00000000000..94b21fa8bb6 --- /dev/null +++ b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/Program.cs @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how an application can own its own ASP.NET Core route and expose a workflow over the +// OpenAI Responses protocol. It uses the OpenAIResponses conversion helpers for the wire protocol and +// HostedWorkflowState for per-session checkpoint resume. The application keeps control of routing, auth, +// and checkpoint storage. +// +// Mirroring the Python local_responses_workflow sample, this server demonstrates previous_response_id +// continuation ONLY. It rejects conversation-id continuity with HTTP 400. Because previous_response_id +// rotates every turn, the app owns a cursor store that maps each response id to the stable workflow +// session id, so the whole rotating chain resumes the same checkpointed run (the .NET equivalent of the +// Python sample's file-backed CheckpointCursorStore). + +using System.Collections.Concurrent; +using System.Text.Json; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Hosting.OpenAI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +var builder = WebApplication.CreateBuilder(args); + +// Configuration via environment variables (never hardcode secrets). +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; + +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +var projectClient = new AIProjectClient(new Uri(endpoint), new AzureCliCredential()); +AIAgent writer = projectClient.AsAIAgent( + model: model, + instructions: "You are an excellent slogan writer. Create one short slogan from the given brief.", + name: "writer"); + +// Workflow shape (mirrors the Python sample): a brief adapter turns the Responses input into the writer's +// prompt and drives the agent turn, then a formatter renders the writer's output as a single slogan line. +var briefExecutor = new BriefExecutor(); +var formatterExecutor = new SloganFormatterExecutor(); + +Workflow workflow = new WorkflowBuilder(briefExecutor) + .AddEdge(briefExecutor, writer) + .AddEdge(writer, formatterExecutor) + .WithOutputFrom(formatterExecutor) + .Build(); + +// Optional shared execution state: pairs the workflow with an in-memory CheckpointManager and a per-session +// sessionId -> CheckpointInfo head cursor so a session can resume from its last checkpoint. +var state = new HostedWorkflowState(workflow); + +// App-owned response-id -> workflow-session-id cursor. previous_response_id rotates each turn, so every id in +// a conversation's chain maps to the same workflow session, and resuming any of them restores that session's +// latest checkpoint. In-memory for this local sample; a real app persists this per tenant/user. +var responseToSession = new ConcurrentDictionary(StringComparer.Ordinal); + +var app = builder.Build(); + +// The application owns this route. Binding the body as JsonElement lets ASP.NET Core deserialize the JSON +// request body directly, so there is no JsonDocument to own or dispose. +app.MapPost("/responses", async (JsonElement body, CancellationToken cancellationToken) => +{ + OpenAIResponsesRunRequest run; + try + { + run = OpenAIResponses.ToAgentRunRequest(body); + } + catch (ArgumentException) + { + return Results.BadRequest(); + } + + // This sample supports previous_response_id continuation only. GetSessionId returns previous_response_id + // first, otherwise the conversation id; anything that is not a "resp_" id is a conversation id, which this + // server does not implement. The candidate is untrusted: a real app authenticates the caller and + // authorizes/binds it before use. + string? candidate = OpenAIResponses.GetSessionId(body); + if (candidate?.StartsWith("resp_", StringComparison.Ordinal) == false) + { + return Results.Problem( + detail: "This server supports previous_response_id continuation only; conversation is not implemented.", + statusCode: StatusCodes.Status400BadRequest); + } + + string? previousResponseId = candidate; + string responseId = OpenAIResponses.CreateResponseId(); + + // Resolve the workflow session: continue the chain's session when previous_response_id is known, otherwise + // start a fresh workflow continuation. + string sessionId = previousResponseId is not null && responseToSession.TryGetValue(previousResponseId, out string? existing) + ? existing + : Guid.NewGuid().ToString("N"); + + // Runs the workflow forward on the first call for this session, or restores the session's latest checkpoint + // and runs forward with this turn's brief thereafter, then records the new head checkpoint. + string brief = ExtractBrief(run.Messages); + HostedWorkflowRunResult result = await state.RunOrResumeAsync(sessionId, brief, cancellationToken).ConfigureAwait(false); + + // Map this response id onto the workflow session so the next previous_response_id continues the same run. + responseToSession[responseId] = sessionId; + + AgentResponse response = BuildWorkflowResponse(result); + return Results.Json(OpenAIResponses.WriteResponse(response, responseId, previousResponseId)); +}); + +// Bind to a fixed local URL so the paired client sample has a deterministic default. +// Override with the ASPNETCORE_URLS environment variable when needed. +app.Run("http://localhost:5001"); + +// Flattens the Responses input messages into a single brief string for the workflow's start executor. +static string ExtractBrief(IEnumerable messages) + => string.Join("\n", messages.Select(m => m.Text).Where(t => !string.IsNullOrWhiteSpace(t))).Trim(); + +// Extracts the workflow's final string output (the formatted slogan) from its output events, falling back to a +// short run summary when the workflow emitted no string output this turn. +static AgentResponse BuildWorkflowResponse(HostedWorkflowRunResult result) +{ + string? slogan = null; + foreach (WorkflowEvent evt in result.Events) + { + if (evt is WorkflowOutputEvent output && output.Data is string text && !string.IsNullOrWhiteSpace(text)) + { + slogan = text; + } + } + + return new AgentResponse(new ChatMessage( + ChatRole.Assistant, + slogan ?? $"{result.Events.Count} workflow event(s) processed.")); +} + +/// +/// Adapts the Responses brief into the writer agent's turn. It builds the writer prompt from the brief (a plain +/// topic string or a JSON object with topic/style/audience), sends it as a user message, and emits the +/// that drives the downstream agent. This keeps the workflow non-chat-protocol (its +/// output is a plain string) while still driving the agent, mirroring the Python sample's prompt builder. +/// +[SendsMessage(typeof(ChatMessage))] +[SendsMessage(typeof(TurnToken))] +internal sealed class BriefExecutor() : Executor("brief") +{ + public override async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + string topic = message.Trim(); + string style = "modern"; + string audience = "general"; + + if (topic.StartsWith('{')) + { + try + { + using JsonDocument doc = JsonDocument.Parse(topic); + JsonElement root = doc.RootElement; + if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("topic", out JsonElement topicElement)) + { + topic = topicElement.GetString() ?? topic; + style = root.TryGetProperty("style", out JsonElement styleElement) ? styleElement.GetString() ?? style : style; + audience = root.TryGetProperty("audience", out JsonElement audienceElement) ? audienceElement.GetString() ?? audience : audience; + } + } + catch (JsonException) + { + // Not a JSON brief; treat the whole text as the topic. + } + } + + if (string.IsNullOrWhiteSpace(topic)) + { + topic = "a generic product"; + } + + string prompt = + $"Topic: {topic}\n" + + $"Style: {style}\n" + + $"Audience: {audience}\n\n" + + "Write a single short slogan that fits the topic, style, and audience."; + + await context.SendMessageAsync(new ChatMessage(ChatRole.User, prompt), cancellationToken: cancellationToken).ConfigureAwait(false); + await context.SendMessageAsync(new TurnToken(emitEvents: true), cancellationToken: cancellationToken).ConfigureAwait(false); + } +} + +/// +/// Formats the writer agent's output as the workflow's final response: one terminal-friendly slogan line. +/// +internal sealed class SloganFormatterExecutor() : Executor, string>("terminal_formatter") +{ + public override ValueTask HandleAsync(List message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + string slogan = string.Join("\n", message.Select(m => m.Text ?? string.Empty)).Trim().Trim('"'); + return ValueTask.FromResult($"Slogan: \"{slogan}\""); + } +} diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/README.md b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/README.md new file mode 100644 index 00000000000..dcbae86b6aa --- /dev/null +++ b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/README.md @@ -0,0 +1,31 @@ +# Server (Hosting Responses Workflow) + +Server half of the [Hosting Responses Workflow](../README.md) sample. + +Exposes a workflow over the OpenAI Responses protocol on an app-owned `POST /responses` route. The workflow +is a brief adapter, a slogan-writer agent, and a formatter that renders one slogan line. It uses the +`OpenAIResponses` conversion helpers for the wire protocol and `HostedWorkflowState` for per-session +checkpoint resume. + +Like the Python sample, this server supports **`previous_response_id` continuation only** and **rejects +`conversation` continuity with HTTP 400**. Because `previous_response_id` rotates every turn, the app owns a +cursor that maps each response id to the stable workflow session id, so the whole rotating chain resumes the +same checkpointed run. The first turn runs the workflow forward; later turns restore the latest checkpoint +and run forward with the new brief. Binds to `http://localhost:5001` (override with `ASPNETCORE_URLS`). + +```bash +export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +export FOUNDRY_MODEL="gpt-5.4-mini" +dotnet run +``` + +Call it directly, following the response-id chain across turns (the second call continues the first): + +```bash +curl -s http://localhost:5001/responses -H "content-type: application/json" \ + -d '{ "input": "{\"topic\": \"electric SUV\", \"style\": \"playful\", \"audience\": \"young families\"}" }' + +# Take the "id" (resp_...) from the response above and pass it as previous_response_id: +curl -s http://localhost:5001/responses -H "content-type: application/json" \ + -d '{ "input": "Make it a little more premium.", "previous_response_id": "resp_..." }' +``` \ No newline at end of file diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/Server.csproj b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/Server.csproj new file mode 100644 index 00000000000..3831a5c20cc --- /dev/null +++ b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/Server.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + From 6ec892773f78033e97d2bd500671c0c20dc43902 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:33:01 +0100 Subject: [PATCH 19/28] Clarify hosting sample README wording and drop Python comparisons --- .../samples/04-hosting/af-hosting/README.md | 55 ++++++++++++------- .../local_responses/Client/Program.cs | 2 +- .../af-hosting/local_responses/README.md | 8 +-- .../local_responses/Server/Program.cs | 2 +- .../local_responses/Server/README.md | 5 +- .../Client/Program.cs | 2 +- .../local_responses_workflow/README.md | 12 ++-- .../Server/Program.cs | 15 +++-- .../local_responses_workflow/Server/README.md | 12 ++-- 9 files changed, 63 insertions(+), 50 deletions(-) diff --git a/dotnet/samples/04-hosting/af-hosting/README.md b/dotnet/samples/04-hosting/af-hosting/README.md index 2a2f7845913..5760e154e5f 100644 --- a/dotnet/samples/04-hosting/af-hosting/README.md +++ b/dotnet/samples/04-hosting/af-hosting/README.md @@ -1,38 +1,53 @@ -# Agent Framework hosting helper samples +# Agent Framework hosting samples (bring your own route) -End-to-end samples for exposing Agent Framework targets through app-owned hosting routes. +These samples show how to expose an Agent Framework agent or workflow over the OpenAI Responses HTTP +protocol from an ASP.NET Core app that you write, where your app owns the HTTP route, authentication, and +where conversations are stored. -The helper-first hosting packages provide protocol conversion and optional execution state. The -application still owns the web framework, native SDK clients, authentication, response construction, and -deployment shape. +## Two ways to expose an agent over the Responses protocol + +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. + +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. + The framework only does the protocol translation, so you keep full control of routing, authentication, + and where conversations are stored. Pick this when you need hosting behavior the built-in endpoint does + not provide. + +## Samples | Sample | What it shows | |---|---| -| [`local_responses/`](./local_responses) | One agent behind an app-owned ASP.NET Core route using the `OpenAIResponses` helper functions plus `HostedAgentState` / `AgentSessionStore`. Start here to learn the helper seam. | -| [`local_responses_workflow/`](./local_responses_workflow) | A workflow target behind an app-owned ASP.NET Core route using the `OpenAIResponses` helper functions, `HostedWorkflowState`, an explicit `CheckpointManager`, and an app-owned checkpoint cursor. | +| [`local_responses/`](./local_responses) | An agent behind an ASP.NET Core route you write, using the `OpenAIResponses` helper methods plus `HostedAgentState` / `AgentSessionStore` for conversation continuity. The simplest sample to start with. | +| [`local_responses_workflow/`](./local_responses_workflow) | A workflow behind an ASP.NET Core route you write, using the `OpenAIResponses` helper methods, `HostedWorkflowState`, an explicit `CheckpointManager`, and a checkpoint cursor your app keeps so a run resumes across turns. | -Each sample is a **client/server pair**. Unlike the Python samples (which run a server `app.py` and calling -scripts as loose files), .NET samples are projects, so each pair is split into two projects: +Each sample is a **client/server pair** split into two projects: ``` local_responses/ -├── Server/ # exposes POST /responses using the OpenAIResponses helpers -└── Client/ # consumes it two ways: CC (IChatClient) and MAF (AIAgent) +├── Server/ # exposes POST /responses using the OpenAIResponses helper methods +└── Client/ # consumes it two ways: a chat client and an agent ``` -The `Client` shows the two idiomatic ways to consume the endpoint from .NET, both against the same server: +The `Client` shows the two ways to consume the endpoint from .NET, both against the same server: -- **CC** — a plain `Microsoft.Extensions.AI.IChatClient` (the lower-level chat-client path). -- **MAF** — a Microsoft Agent Framework `AIAgent` (the higher-level agent path). +- A plain `Microsoft.Extensions.AI.IChatClient` (the lower-level chat-client path). +- A Microsoft Agent Framework `AIAgent` (the higher-level agent path). -## Relationship to `../foundry-hosted-agents/` +## Relationship to `../FoundryHostedAgents/` The sibling [`../FoundryHostedAgents/`](../FoundryHostedAgents) directory contains samples for agents that -run inside the Foundry Hosted Agents platform. Those samples use the Foundry-managed protocol surface with -no app-owned hosting route involved. +run inside the Foundry Hosted Agents platform, which hosts the agent and exposes the protocol for you. Use +those when you want the Foundry-managed hosting surface; use these when you want to host the agent in your +own ASP.NET Core app. | Aspect | `af-hosting/` (this directory) | `FoundryHostedAgents/` | |---|---|---| -| Server stack | App-owned ASP.NET Core + hosting protocol helpers | Foundry Hosted Agents runtime | -| Protocol surface | The app exposes the route and calls helpers | The platform exposes Responses + Invocations | -| When to pick this | You need custom hosting code or want to learn the helper seam | You want the Foundry-managed hosting surface | +| Server stack | An ASP.NET Core app you write plus the hosting protocol helpers | Foundry Hosted Agents runtime | +| Who exposes the route | Your app | The platform | +| When to pick this | You need custom hosting code | You want the Foundry-managed hosting surface | \ No newline at end of file diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Client/Program.cs b/dotnet/samples/04-hosting/af-hosting/local_responses/Client/Program.cs index 8c92187d3ce..796a07e6058 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/Client/Program.cs +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Client/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. // Client for the HostingResponsesAgent server sample. It shows the two idiomatic ways to consume an -// OpenAI Responses endpoint from .NET, both pointed at the same app-owned server route: +// OpenAI Responses endpoint from .NET, both pointed at the same server route (written in the paired Server): // // 1. CC - a plain Microsoft.Extensions.AI IChatClient (the lower-level chat-client path). // 2. MAF - a Microsoft Agent Framework AIAgent + AgentSession (the higher-level agent path). diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/README.md b/dotnet/samples/04-hosting/af-hosting/local_responses/README.md index eb3385229fb..a3f1c3f8ae4 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/README.md +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/README.md @@ -1,7 +1,7 @@ # Hosting Responses Agent (client / server) A client/server pair showing how to expose an `AIAgent` over the OpenAI Responses protocol from an -app-owned ASP.NET Core route, and how to consume it from .NET two different ways. +ASP.NET Core route you write, and how to consume it from .NET two different ways. ``` local_responses/ @@ -13,9 +13,9 @@ local_responses/ The server owns routing, authentication, and session storage. The framework provides only the protocol conversion via `OpenAIResponses` (`GetSessionId`, `ToAgentRunRequest`, `WriteResponse` / -`WriteResponseStreamAsync`), instead of the batteries-included `MapOpenAIResponses` server. The agent has a -deterministic `lookup_weather` tool (mirroring the Python sample). Session continuity uses `HostedAgentState` -over an in-memory `AgentSessionStore`. It binds to `http://localhost:5000`. +`WriteResponseStreamAsync`), instead of the batteries-included `MapOpenAIResponses` endpoint. The agent has a +deterministic `lookup_weather` tool. Session continuity uses `HostedAgentState` over an in-memory +`AgentSessionStore`. It binds to `http://localhost:5000`. See [Server/README.md](Server/README.md). diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs index 899b49f2bbf..c10700fa595 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs @@ -21,7 +21,7 @@ ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; -// A deterministic weather tool (mirrors the Python sample's lookup_weather @tool). +// A deterministic weather tool. [Description("Return a deterministic weather report for a city.")] static string LookupWeather([Description("The city to look up weather for.")] string location) { diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md index 66e0e9a1499..9b56fb83da9 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md @@ -2,7 +2,7 @@ Server half of the [Hosting Responses Agent](../README.md) sample. -Exposes an `AIAgent` over the OpenAI Responses protocol on an app-owned `POST /responses` route: +Exposes an `AIAgent` over the OpenAI Responses protocol on a `POST /responses` route you write: - `OpenAIResponses.GetSessionId(body)` extracts the untrusted continuation-id candidate. - `OpenAIResponses.ToAgentRunRequest(body)` parses the request into messages + run options. @@ -10,8 +10,7 @@ Exposes an `AIAgent` over the OpenAI Responses protocol on an app-owned `POST /r Responses wire shape (non-streaming JSON and SSE). Session continuity uses `HostedAgentState` over an in-memory `AgentSessionStore`. The agent has a -deterministic `lookup_weather` tool (mirroring the Python sample). Binds to `http://localhost:5000` (override -with `ASPNETCORE_URLS`). +deterministic `lookup_weather` tool. Binds to `http://localhost:5000` (override with `ASPNETCORE_URLS`). ```bash export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Client/Program.cs b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Client/Program.cs index 29b9852669a..292834f932f 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Client/Program.cs +++ b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Client/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. // Client for the local_responses_workflow server sample. Like the agent client, it shows the two idiomatic -// ways to consume an OpenAI Responses endpoint from .NET, both pointed at the same app-owned workflow route: +// ways to consume an OpenAI Responses endpoint from .NET, both pointed at the same workflow route (written in the paired Server): // // 1. CC - a plain Microsoft.Extensions.AI IChatClient (the lower-level chat-client path). // 2. MAF - a Microsoft Agent Framework AIAgent + AgentSession (the higher-level agent path). diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/README.md b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/README.md index b6719528309..a832247bbc9 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/README.md +++ b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/README.md @@ -1,8 +1,8 @@ # Hosting Responses Workflow (client / server) A client/server pair showing how to expose a **workflow** over the OpenAI Responses protocol from an -app-owned ASP.NET Core route with per-session checkpoint resume, and how to consume it from .NET two -different ways. This mirrors the Python `local_responses_workflow` sample. +ASP.NET Core route you write, with per-session checkpoint resume, and how to consume it from .NET two +different ways. ``` local_responses_workflow/ @@ -18,10 +18,10 @@ brief adapter, a slogan-writer agent, and a formatter that renders one slogan li workflow forward; later turns restore the latest checkpoint and run forward with the new brief. It binds to `http://localhost:5001`. -Like the Python sample, the server supports **`previous_response_id` continuation only** and **rejects -`conversation` continuity with HTTP 400**. Because `previous_response_id` rotates every turn, the app owns a -cursor that maps each response id to the stable workflow session id (the .NET equivalent of the Python -sample's `CheckpointCursorStore`). +The server supports **`previous_response_id` continuation only** and **rejects `conversation` continuity +with HTTP 400**. Because `previous_response_id` rotates every turn, the app owns a cursor that maps each +response id to the stable workflow session id, so the whole rotating chain resumes the same checkpointed +run. See [Server/README.md](Server/README.md). diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/Program.cs b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/Program.cs index 94b21fa8bb6..27809532e49 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/Program.cs +++ b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/Program.cs @@ -5,11 +5,10 @@ // HostedWorkflowState for per-session checkpoint resume. The application keeps control of routing, auth, // and checkpoint storage. // -// Mirroring the Python local_responses_workflow sample, this server demonstrates previous_response_id -// continuation ONLY. It rejects conversation-id continuity with HTTP 400. Because previous_response_id -// rotates every turn, the app owns a cursor store that maps each response id to the stable workflow -// session id, so the whole rotating chain resumes the same checkpointed run (the .NET equivalent of the -// Python sample's file-backed CheckpointCursorStore). +// This server demonstrates previous_response_id continuation ONLY. It rejects conversation-id continuity +// with HTTP 400. Because previous_response_id rotates every turn, the app owns a cursor store that maps each +// response id to the stable workflow session id, so the whole rotating chain resumes the same checkpointed +// run. using System.Collections.Concurrent; using System.Text.Json; @@ -37,7 +36,7 @@ instructions: "You are an excellent slogan writer. Create one short slogan from the given brief.", name: "writer"); -// Workflow shape (mirrors the Python sample): a brief adapter turns the Responses input into the writer's +// Workflow shape: a brief adapter turns the Responses input into the writer's // prompt and drives the agent turn, then a formatter renders the writer's output as a single slogan line. var briefExecutor = new BriefExecutor(); var formatterExecutor = new SloganFormatterExecutor(); @@ -52,7 +51,7 @@ // sessionId -> CheckpointInfo head cursor so a session can resume from its last checkpoint. var state = new HostedWorkflowState(workflow); -// App-owned response-id -> workflow-session-id cursor. previous_response_id rotates each turn, so every id in +// The app keeps a response-id -> workflow-session-id cursor. previous_response_id rotates each turn, so every id in // a conversation's chain maps to the same workflow session, and resuming any of them restores that session's // latest checkpoint. In-memory for this local sample; a real app persists this per tenant/user. var responseToSession = new ConcurrentDictionary(StringComparer.Ordinal); @@ -136,7 +135,7 @@ static AgentResponse BuildWorkflowResponse(HostedWorkflowRunResult result) /// Adapts the Responses brief into the writer agent's turn. It builds the writer prompt from the brief (a plain /// topic string or a JSON object with topic/style/audience), sends it as a user message, and emits the /// that drives the downstream agent. This keeps the workflow non-chat-protocol (its -/// output is a plain string) while still driving the agent, mirroring the Python sample's prompt builder. +/// output is a plain string) while still driving the agent. /// [SendsMessage(typeof(ChatMessage))] [SendsMessage(typeof(TurnToken))] diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/README.md b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/README.md index dcbae86b6aa..2d372da9bec 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/README.md +++ b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/README.md @@ -2,16 +2,16 @@ Server half of the [Hosting Responses Workflow](../README.md) sample. -Exposes a workflow over the OpenAI Responses protocol on an app-owned `POST /responses` route. The workflow +Exposes a workflow over the OpenAI Responses protocol on a `POST /responses` route you write. The workflow is a brief adapter, a slogan-writer agent, and a formatter that renders one slogan line. It uses the `OpenAIResponses` conversion helpers for the wire protocol and `HostedWorkflowState` for per-session checkpoint resume. -Like the Python sample, this server supports **`previous_response_id` continuation only** and **rejects -`conversation` continuity with HTTP 400**. Because `previous_response_id` rotates every turn, the app owns a -cursor that maps each response id to the stable workflow session id, so the whole rotating chain resumes the -same checkpointed run. The first turn runs the workflow forward; later turns restore the latest checkpoint -and run forward with the new brief. Binds to `http://localhost:5001` (override with `ASPNETCORE_URLS`). +This server supports **`previous_response_id` continuation only** and **rejects `conversation` continuity +with HTTP 400**. Because `previous_response_id` rotates every turn, the app owns a cursor that maps each +response id to the stable workflow session id, so the whole rotating chain resumes the same checkpointed +run. The first turn runs the workflow forward; later turns restore the latest checkpoint and run forward +with the new brief. Binds to `http://localhost:5001` (override with `ASPNETCORE_URLS`). ```bash export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" From 96a59b51d41868e10e04463b21293ce5669f6375 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:27:22 +0100 Subject: [PATCH 20/28] Make AgentSessionStore.DeleteSessionAsync abstract and rename session id parameter to sessionStoreId --- .../AgentSessionStore.cs | 39 +++++++++-------- .../DelegatingAgentSessionStore.cs | 12 +++--- .../HostedAgentState.cs | 42 +++++++++---------- .../IsolationKeyScopedAgentSessionStore.cs | 34 +++++++-------- .../Local/InMemoryAgentSessionStore.cs | 18 ++++---- .../NoopAgentSessionStore.cs | 6 +-- .../DelegatingAgentSessionStoreTests.cs | 7 +++- .../InMemoryAgentSessionStoreTests.cs | 11 +++-- ...solationKeyScopedAgentSessionStoreTests.cs | 7 +++- 9 files changed, 92 insertions(+), 84 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs index 4fe31164def..66ec42951c2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs @@ -17,17 +17,17 @@ namespace Microsoft.Agents.AI.Hosting; /// or different service instances in hosted scenarios. /// /// -/// Trust model. The conversationId passed to -/// and typically originates -/// from the wire (for example, an AG-UI RunAgentInput.ThreadId or an A2A -/// contextId). It is a chain-resume identifier, not an authorization -/// token, and the (agent, conversationId) tuple carries no principal/owner +/// Trust model. The sessionStoreId passed to +/// and is the id under which the session is +/// stored. It typically originates from the wire (for example, an AG-UI RunAgentInput.ThreadId or an +/// A2A contextId). It is a chain-resume identifier, not an authorization +/// token, and the (agent, sessionStoreId) tuple carries no principal/owner /// dimension. Hosts that serve more than one user from the same registered store must /// therefore compose a principal dimension into the lookup key, otherwise any caller -/// who knows or guesses another caller's conversationId can resume +/// who knows or guesses another caller's sessionStoreId can resume /// that other caller's persisted thread. The framework provides /// as a decorator that rewrites -/// conversationId to include an isolation key resolved from a +/// sessionStoreId to include an isolation key resolved from a /// (for example, the ASP.NET Core /// ClaimsIdentitySessionIsolationKeyProvider wired up via /// UseClaimsBasedSessionIsolation(...)). When no provider is registered, the @@ -36,12 +36,12 @@ namespace Microsoft.Agents.AI.Hosting; /// /// /// Implementer guidance. Implementations should treat -/// conversationId as opaque: do not parse it, do not impose length +/// sessionStoreId as opaque: do not parse it, do not impose length /// or character-set constraints on it, and do not assume it round-trips to the value /// the caller originally supplied (decorators such as /// may rewrite it before forwarding). /// Be aware that any logging, telemetry, or audit sink that surfaces -/// conversationId will also surface the isolation prefix when a +/// sessionStoreId will also surface the isolation prefix when a /// scoping decorator is in the chain. /// /// @@ -51,13 +51,13 @@ public abstract class AgentSessionStore /// Saves a serialized agent session to persistent storage. /// /// The agent that owns this session. - /// The unique identifier for the conversation/session. + /// The id under which the session is stored. /// The session to save. /// The to monitor for cancellation requests. /// A task that represents the asynchronous save operation. public abstract ValueTask SaveSessionAsync( AIAgent agent, - string conversationId, + string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default); @@ -65,7 +65,7 @@ public abstract ValueTask SaveSessionAsync( /// Retrieves a serialized agent session from persistent storage. /// /// The agent that owns this session. - /// The unique identifier for the conversation/session to retrieve. + /// The id under which the session is stored. /// The to monitor for cancellation requests. /// /// A task that represents the asynchronous retrieval operation. @@ -73,26 +73,25 @@ public abstract ValueTask SaveSessionAsync( /// public abstract ValueTask GetSessionAsync( AIAgent agent, - string conversationId, + string sessionStoreId, CancellationToken cancellationToken = default); /// /// Deletes a stored agent session, if present. /// /// The agent that owns this session. - /// The unique identifier for the conversation/session to delete. + /// The id under which the session is stored. /// The to monitor for cancellation requests. /// A task that represents the asynchronous delete operation. /// - /// The default implementation throws . Stores that support removal - /// override this method. Deleting a missing session is a no-op for stores that override it. + /// Implementations that support removal delete the session and treat a missing session as a no-op. + /// Implementations that genuinely cannot support deletion should throw . /// /// The store does not support deletion. - public virtual ValueTask DeleteSessionAsync( + public abstract ValueTask DeleteSessionAsync( AIAgent agent, - string conversationId, - CancellationToken cancellationToken = default) - => throw new NotSupportedException($"{this.GetType().Name} does not support session deletion."); + string sessionStoreId, + CancellationToken cancellationToken = default); /// Asks the for an object of the specified type . /// The type of object being requested. diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs index faa4e9bd1cf..f340cec8af5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs @@ -53,16 +53,16 @@ protected DelegatingAgentSessionStore(AgentSessionStore innerStore) protected AgentSessionStore InnerStore { get; } /// - public override ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) - => this.InnerStore.GetSessionAsync(agent, conversationId, cancellationToken); + public override ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) + => this.InnerStore.GetSessionAsync(agent, sessionStoreId, cancellationToken); /// - public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) - => this.InnerStore.SaveSessionAsync(agent, conversationId, session, cancellationToken); + public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) + => this.InnerStore.SaveSessionAsync(agent, sessionStoreId, session, cancellationToken); /// - public override ValueTask DeleteSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) - => this.InnerStore.DeleteSessionAsync(agent, conversationId, cancellationToken); + public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) + => this.InnerStore.DeleteSessionAsync(agent, sessionStoreId, cancellationToken); /// /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs index fef9a0bf58d..1b12a63990c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs @@ -21,7 +21,7 @@ namespace Microsoft.Agents.AI.Hosting; /// which already provides serialization and per-principal isolation. /// /// -/// Trust boundary. The sessionId values passed to these methods are +/// Trust boundary. The sessionStoreId values passed to these methods are /// application-selected partition keys. When a key originates from the wire (for example via /// OpenAIResponses.GetSessionId(...)), the application must authenticate the caller and authorize /// the key before using it here. For multi-user hosts, scope the underlying store per principal with @@ -56,65 +56,65 @@ public HostedAgentState(AIAgent agent, AgentSessionStore? sessionStore = null, b } /// - /// Returns the stored session for , creating a new session on first use. + /// Returns the stored session for , creating a new session on first use. /// - /// The application-selected session id. + /// The application-selected id under which the session is stored. /// The to monitor for cancellation requests. /// The resolved or newly created . - public ValueTask GetOrCreateSessionAsync(string sessionId, CancellationToken cancellationToken = default) + public ValueTask GetOrCreateSessionAsync(string sessionStoreId, CancellationToken cancellationToken = default) { - _ = Throw.IfNullOrEmpty(sessionId); - return this._sessionStore.GetSessionAsync(this._agent, sessionId, cancellationToken); + _ = Throw.IfNullOrEmpty(sessionStoreId); + return this._sessionStore.GetSessionAsync(this._agent, sessionStoreId, cancellationToken); } /// - /// Persists under . Call this after the run + /// Persists under . Call this after the run /// completes, including under a newly minted continuation id when the protocol mints one. /// - /// The application-selected session id (may be a newly minted id). + /// The application-selected id under which the session is stored (may be a newly minted id). /// The session to persist. /// The to monitor for cancellation requests. /// A task representing the asynchronous save operation. - public ValueTask SaveSessionAsync(string sessionId, AgentSession session, CancellationToken cancellationToken = default) + public ValueTask SaveSessionAsync(string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) { - _ = Throw.IfNullOrEmpty(sessionId); + _ = Throw.IfNullOrEmpty(sessionStoreId); _ = Throw.IfNull(session); - return this._sessionStore.SaveSessionAsync(this._agent, sessionId, session, cancellationToken); + return this._sessionStore.SaveSessionAsync(this._agent, sessionStoreId, session, cancellationToken); } /// - /// Deletes the stored session for , if present. + /// Deletes the stored session for , if present. /// - /// The application-selected session id. + /// The application-selected id under which the session is stored. /// The to monitor for cancellation requests. /// A task representing the asynchronous delete operation. /// The underlying store does not support deletion. - public ValueTask DeleteSessionAsync(string sessionId, CancellationToken cancellationToken = default) + public ValueTask DeleteSessionAsync(string sessionStoreId, CancellationToken cancellationToken = default) { - _ = Throw.IfNullOrEmpty(sessionId); - return this._sessionStore.DeleteSessionAsync(this._agent, sessionId, cancellationToken); + _ = Throw.IfNullOrEmpty(sessionStoreId); + return this._sessionStore.DeleteSessionAsync(this._agent, sessionStoreId, cancellationToken); } /// - /// Acquires an exclusive lock for so concurrent requests for the same + /// Acquires an exclusive lock for so concurrent requests for the same /// session serialize their get-run-save cycle. Dispose the returned value to release the lock. /// - /// The application-selected session id. + /// The application-selected id under which the session is stored. /// The to monitor for cancellation requests. /// An that releases the lock when disposed. /// /// When session locking is not enabled, this returns immediately with a no-op releaser. /// - public async ValueTask LockSessionAsync(string sessionId, CancellationToken cancellationToken = default) + public async ValueTask LockSessionAsync(string sessionStoreId, CancellationToken cancellationToken = default) { - _ = Throw.IfNullOrEmpty(sessionId); + _ = Throw.IfNullOrEmpty(sessionStoreId); if (this._sessionLocks is null) { return NoopReleaser.Instance; } - SemaphoreSlim gate = this._sessionLocks.GetOrAdd(sessionId, static _ => new SemaphoreSlim(1, 1)); + SemaphoreSlim gate = this._sessionLocks.GetOrAdd(sessionStoreId, static _ => new SemaphoreSlim(1, 1)); await gate.WaitAsync(cancellationToken).ConfigureAwait(false); return new SemaphoreReleaser(gate); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs index 1ee4c46b44a..87c33a567a9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs @@ -63,54 +63,54 @@ public IsolationKeyScopedAgentSessionStore( } /// - /// Escapes special characters in the isolation key to ensure unambiguous scoped conversation IDs. + /// Escapes special characters in the isolation key to ensure unambiguous scoped session store IDs. /// /// The raw isolation key. /// The escaped isolation key. /// /// Backslashes are escaped first (\ becomes \\), then colons (: becomes \:). - /// This ensures the scoped conversation ID format {key}::{conversationId} can be parsed correctly. + /// This ensures the scoped session store ID format {key}::{sessionStoreId} can be parsed correctly. /// private static string EscapeIsolationKey(string key) => key.Replace("\\", "\\\\").Replace(":", "\\:"); /// - /// Constructs a scoped conversation ID by prefixing the bare conversation ID with the escaped isolation key. + /// Constructs a scoped session store ID by prefixing the bare session store ID with the escaped isolation key. /// - /// The original conversation ID. + /// The original session store ID. /// The cancellation token. /// - /// The scoped conversation ID in the format {escapedKey}::{conversationId}, or the bare conversation ID + /// The scoped session store ID in the format {escapedKey}::{sessionStoreId}, or the bare session store ID /// if no isolation key is available and non-strict mode is enabled. /// - private async ValueTask GetScopedConversationIdAsync(string bareConversationId, CancellationToken cancellationToken) + private async ValueTask GetScopedSessionStoreIdAsync(string bareSessionStoreId, CancellationToken cancellationToken) { string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false); if (key == null) { - return bareConversationId; + return bareSessionStoreId; } - return $"{EscapeIsolationKey(key)}::{bareConversationId}"; + return $"{EscapeIsolationKey(key)}::{bareSessionStoreId}"; } /// - public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + public override async ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) { - string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false); - return await this.InnerStore.GetSessionAsync(agent, scopedConversationId, cancellationToken).ConfigureAwait(false); + string scopedSessionStoreId = await this.GetScopedSessionStoreIdAsync(sessionStoreId, cancellationToken).ConfigureAwait(false); + return await this.InnerStore.GetSessionAsync(agent, scopedSessionStoreId, cancellationToken).ConfigureAwait(false); } /// - public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) + public override async ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) { - string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false); - await this.InnerStore.SaveSessionAsync(agent, scopedConversationId, session, cancellationToken).ConfigureAwait(false); + string scopedSessionStoreId = await this.GetScopedSessionStoreIdAsync(sessionStoreId, cancellationToken).ConfigureAwait(false); + await this.InnerStore.SaveSessionAsync(agent, scopedSessionStoreId, session, cancellationToken).ConfigureAwait(false); } /// - public override async ValueTask DeleteSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + public override async ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) { - string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false); - await this.InnerStore.DeleteSessionAsync(agent, scopedConversationId, cancellationToken).ConfigureAwait(false); + string scopedSessionStoreId = await this.GetScopedSessionStoreIdAsync(sessionStoreId, cancellationToken).ConfigureAwait(false); + await this.InnerStore.DeleteSessionAsync(agent, scopedSessionStoreId, cancellationToken).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs index d9a8f3613d6..2c17d7e2f3b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs @@ -26,8 +26,8 @@ namespace Microsoft.Agents.AI.Hosting; /// /// /// Multi-user warning. This store keys threads by -/// (agent.Id, conversationId) only — it has no principal/owner dimension. When -/// the conversation identifier originates from the wire (for example, an AG-UI +/// (agent.Id, sessionStoreId) only — it has no principal/owner dimension. When +/// the session store id originates from the wire (for example, an AG-UI /// RunAgentInput.ThreadId or an A2A contextId), any caller who knows /// or guesses another caller's identifier can resume that other caller's persisted /// thread. Multi-user hosts must wrap this store in @@ -44,16 +44,16 @@ public sealed class InMemoryAgentSessionStore : AgentSessionStore private readonly ConcurrentDictionary _threads = new(); /// - public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) + public override async ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) { - var key = GetKey(conversationId, agent.Id); + var key = GetKey(sessionStoreId, agent.Id); this._threads[key] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); } /// - public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + public override async ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) { - var key = GetKey(conversationId, agent.Id); + var key = GetKey(sessionStoreId, agent.Id); JsonElement? sessionContent = this._threads.TryGetValue(key, out var existingSession) ? existingSession : null; return sessionContent switch @@ -64,11 +64,11 @@ public override async ValueTask GetSessionAsync(AIAgent agent, str } /// - public override ValueTask DeleteSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) { - this._threads.TryRemove(GetKey(conversationId, agent.Id), out _); + this._threads.TryRemove(GetKey(sessionStoreId, agent.Id), out _); return default; } - private static string GetKey(string conversationId, string agentId) => $"{agentId}:{conversationId}"; + private static string GetKey(string sessionStoreId, string agentId) => $"{agentId}:{sessionStoreId}"; } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs index cc1df02a33b..a156285a856 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs @@ -12,19 +12,19 @@ namespace Microsoft.Agents.AI.Hosting; public sealed class NoopAgentSessionStore : AgentSessionStore { /// - public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) + public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) { return default; } /// - public override ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + public override ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) { return agent.CreateSessionAsync(cancellationToken); } /// - public override ValueTask DeleteSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) { return default; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs index f04ad7c20d3..e5f452aa795 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs @@ -387,11 +387,14 @@ private sealed class AnotherDelegatingAgentSessionStore(AgentSessionStore innerS /// private sealed class ConcreteAgentSessionStore : AgentSessionStore { - public override ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + public override ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) => new(new TestAgentSession()); - public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) + public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + + public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); } private sealed class TestAgentSession : AgentSession; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs index dff8864fb34..2e40b351c44 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs @@ -63,9 +63,9 @@ public async Task DeleteSessionAsync_NoopStore_CompletesAsync() } [Fact] - public async Task DeleteSessionAsync_BaseDefault_ThrowsNotSupportedAsync() + public async Task DeleteSessionAsync_StoreOptsOut_ThrowsNotSupportedAsync() { - // Arrange + // Arrange: a store that chooses not to support deletion throws NotSupportedException itself. AgentSessionStore store = new ConcreteAgentSessionStore(); // Act & Assert @@ -76,10 +76,13 @@ private sealed class TestAgentSession : AgentSession; private sealed class ConcreteAgentSessionStore : AgentSessionStore { - public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) + public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) => default; - public override ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + public override ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) => new(new TestAgentSession()); + + public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs index d410543608f..aaaffe797b4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs @@ -419,11 +419,14 @@ private sealed class TestAgentSession : AgentSession; /// private sealed class ConcreteAgentSessionStore : AgentSessionStore { - public override ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + public override ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) => new(new TestAgentSession()); - public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) + public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + + public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); } #endregion From 10531efceaa1b210222f5be0014c3ac69300cd0e Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:19:19 +0100 Subject: [PATCH 21/28] Rename OpenAIResponses id helpers and parse the request once for id extraction --- .../local_responses/Client/Program.cs | 2 +- .../af-hosting/local_responses/README.md | 4 +- .../local_responses/Server/Program.cs | 12 ++-- .../local_responses/Server/README.md | 6 +- .../Server/Program.cs | 18 +++-- .../OpenAIResponses.cs | 65 +++++++++---------- .../OpenAIResponsesRunRequest.cs | 16 ++++- .../OpenAIResponsesHostingLiveTests.cs | 14 ++-- .../OpenAIResponsesHostingTests.cs | 25 ++----- .../OpenAIResponsesTests.cs | 32 ++++++--- 10 files changed, 104 insertions(+), 90 deletions(-) diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Client/Program.cs b/dotnet/samples/04-hosting/af-hosting/local_responses/Client/Program.cs index 796a07e6058..9913f2d9b86 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/Client/Program.cs +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Client/Program.cs @@ -43,7 +43,7 @@ // CC path: consume the endpoint through a Microsoft.Extensions.AI IChatClient. Continuity is threaded by // hand: each response carries the server's response id as ChatResponse.ConversationId, which we pass back // as the next turn's ChatOptions.ConversationId. Because it is a "resp_" id, the SDK sends it as -// previous_response_id, exactly what the server's GetSessionId reads. +// previous_response_id, exactly what the server's GetSessionStoreId reads. static async Task RunWithChatClientAsync(ResponsesClient responseClient, string model, string[] prompts) { Console.WriteLine("== CC: Microsoft.Extensions.AI IChatClient =="); diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/README.md b/dotnet/samples/04-hosting/af-hosting/local_responses/README.md index a3f1c3f8ae4..9ed04fcdefc 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/README.md +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/README.md @@ -12,7 +12,7 @@ local_responses/ ## Server The server owns routing, authentication, and session storage. The framework provides only the protocol -conversion via `OpenAIResponses` (`GetSessionId`, `ToAgentRunRequest`, `WriteResponse` / +conversion via `OpenAIResponses` (`ToAgentRunRequest`, `GetSessionStoreId`, `WriteResponse` / `WriteResponseStreamAsync`), instead of the batteries-included `MapOpenAIResponses` endpoint. The agent has a deterministic `lookup_weather` tool. Session continuity uses `HostedAgentState` over an in-memory `AgentSessionStore`. It binds to `http://localhost:5000`. @@ -51,7 +51,7 @@ The client defaults to `http://localhost:5000`; override with `RESPONSES_SERVER_ ## Security note -`OpenAIResponses.GetSessionId(...)` returns an untrusted candidate key. The server's `Authorize(...)` is a +`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`. diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs index c10700fa595..cf677d58709 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs @@ -56,13 +56,15 @@ static string LookupWeather([Description("The city to look up weather for.")] st // deserialize the JSON request body directly, so there is no JsonDocument to own or dispose. app.MapPost("/responses", async (JsonElement body, HttpContext http, CancellationToken cancellationToken) => { + // Parse the request first, then read the continuation id off the parsed request (no second parse). + OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); + // The candidate continuation id is untrusted. A real app authenticates the caller and authorizes/binds // this key to the principal before using it. This sample simply falls back to a fresh id. - string? candidateSessionId = OpenAIResponses.GetSessionId(body); - string sessionId = Authorize(http, candidateSessionId) ?? OpenAIResponses.CreateResponseId(); + string? candidateSessionStoreId = OpenAIResponses.GetSessionStoreId(run); + string sessionStoreId = Authorize(http, candidateSessionStoreId) ?? OpenAIResponses.CreateResponseId(); - OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); - AgentSession session = await state.GetOrCreateSessionAsync(sessionId, cancellationToken).ConfigureAwait(false); + AgentSession session = await state.GetOrCreateSessionAsync(sessionStoreId, cancellationToken).ConfigureAwait(false); string responseId = OpenAIResponses.CreateResponseId(); bool stream = body.TryGetProperty("stream", out JsonElement streamProp) && streamProp.ValueKind == JsonValueKind.True; @@ -96,4 +98,4 @@ static string LookupWeather([Description("The city to look up weather for.")] st // Application-owned trust decision. Replace with real authentication + authorization: verify the caller, // then authorize/bind the candidate id to the authenticated principal before returning it. -static string? Authorize(HttpContext http, string? candidateSessionId) => candidateSessionId; +static string? Authorize(HttpContext http, string? candidateSessionStoreId) => candidateSessionStoreId; diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md index 9b56fb83da9..0b0b3623471 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md @@ -4,8 +4,10 @@ Server half of the [Hosting Responses Agent](../README.md) sample. Exposes an `AIAgent` over the OpenAI Responses protocol on a `POST /responses` route you write: -- `OpenAIResponses.GetSessionId(body)` extracts the untrusted continuation-id candidate. -- `OpenAIResponses.ToAgentRunRequest(body)` parses the request into messages + run options. +- `OpenAIResponses.ToAgentRunRequest(body)` parses the request into messages, run options, and the + continuation ids. +- `OpenAIResponses.GetSessionStoreId(run)` reads the untrusted continuation-id candidate off the parsed + request. - `OpenAIResponses.WriteResponse(...)` / `WriteResponseStreamAsync(...)` render the agent output back to the Responses wire shape (non-streaming JSON and SSE). diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/Program.cs b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/Program.cs index 27809532e49..4065fc0bd98 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/Program.cs +++ b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/Program.cs @@ -72,34 +72,32 @@ return Results.BadRequest(); } - // This sample supports previous_response_id continuation only. GetSessionId returns previous_response_id - // first, otherwise the conversation id; anything that is not a "resp_" id is a conversation id, which this - // server does not implement. The candidate is untrusted: a real app authenticates the caller and - // authorizes/binds it before use. - string? candidate = OpenAIResponses.GetSessionId(body); - if (candidate?.StartsWith("resp_", StringComparison.Ordinal) == false) + // This sample supports previous_response_id continuation only, read off the already-parsed request. + // A conversation id is not implemented here, so reject it. The candidate is untrusted: a real app + // authenticates the caller and authorizes/binds it before use. + if (run.ConversationId is not null) { return Results.Problem( detail: "This server supports previous_response_id continuation only; conversation is not implemented.", statusCode: StatusCodes.Status400BadRequest); } - string? previousResponseId = candidate; + string? previousResponseId = run.PreviousResponseId; string responseId = OpenAIResponses.CreateResponseId(); // Resolve the workflow session: continue the chain's session when previous_response_id is known, otherwise // start a fresh workflow continuation. - string sessionId = previousResponseId is not null && responseToSession.TryGetValue(previousResponseId, out string? existing) + string sessionStoreId = previousResponseId is not null && responseToSession.TryGetValue(previousResponseId, out string? existing) ? existing : Guid.NewGuid().ToString("N"); // Runs the workflow forward on the first call for this session, or restores the session's latest checkpoint // and runs forward with this turn's brief thereafter, then records the new head checkpoint. string brief = ExtractBrief(run.Messages); - HostedWorkflowRunResult result = await state.RunOrResumeAsync(sessionId, brief, cancellationToken).ConfigureAwait(false); + HostedWorkflowRunResult result = await state.RunOrResumeAsync(sessionStoreId, brief, cancellationToken).ConfigureAwait(false); // Map this response id onto the workflow session so the next previous_response_id continues the same run. - responseToSession[responseId] = sessionId; + responseToSession[responseId] = sessionStoreId; AgentResponse response = BuildWorkflowResponse(result); return Results.Json(OpenAIResponses.WriteResponse(response, responseId, previousResponseId)); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponses.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponses.cs index 8f27b7ef227..d8786f602ad 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponses.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponses.cs @@ -23,9 +23,10 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI; /// and reuse only the protocol conversion. Both share the same internal conversion logic. /// /// -/// Trust boundary. returns an untrusted candidate -/// continuation key. The application must authenticate the caller and authorize/bind the id to the -/// authenticated principal before using it as a session or checkpoint key. The helpers never perform I/O. +/// Trust boundary. returns an +/// untrusted candidate continuation key. The application must authenticate the caller and authorize/bind the +/// id to the authenticated principal before using it as a session or checkpoint key. The helpers never +/// perform I/O. /// /// public static class OpenAIResponses @@ -67,7 +68,7 @@ public static OpenAIResponsesRunRequest ToAgentRunRequest(JsonElement body, Open messages.Add(inputMessage.ToChatMessage()); } - return new OpenAIResponsesRunRequest(messages, options); + return new OpenAIResponsesRunRequest(messages, options, request.PreviousResponseId, request.Conversation?.Id); } /// @@ -75,16 +76,16 @@ public static OpenAIResponsesRunRequest ToAgentRunRequest(JsonElement body, Open /// /// The agent response to render. /// The id to assign to the rendered response (see ). - /// - /// The optional continuation/session id to surface as the response's conversation id. + /// + /// The optional conversation id to surface on the rendered response. /// /// An OpenAI Responses-shaped . - public static JsonElement WriteResponse(AgentResponse response, string responseId, string? sessionId = null) + public static JsonElement WriteResponse(AgentResponse response, string responseId, string? conversationId = null) { ArgumentNullException.ThrowIfNull(response); ArgumentException.ThrowIfNullOrEmpty(responseId); - AgentInvocationContext context = CreateContext(responseId, sessionId); + AgentInvocationContext context = CreateContext(responseId, conversationId); Response wire = response.ToResponse(EmptyRequest(), context); return JsonSerializer.SerializeToElement(wire, OpenAIHostingJsonContext.Default.Response); } @@ -94,19 +95,19 @@ public static JsonElement WriteResponse(AgentResponse response, string responseI /// /// The agent streaming updates. /// The id to assign to the rendered response (see ). - /// The optional continuation/session id to surface as the response's conversation id. + /// The optional conversation id to surface on the rendered response. /// The to monitor for cancellation requests. /// An async sequence of SSE frame strings, each terminated by a blank line. public static async IAsyncEnumerable WriteResponseStreamAsync( IAsyncEnumerable updates, string responseId, - string? sessionId = null, + string? conversationId = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(updates); ArgumentException.ThrowIfNullOrEmpty(responseId); - AgentInvocationContext context = CreateContext(responseId, sessionId); + AgentInvocationContext context = CreateContext(responseId, conversationId); await foreach (StreamingResponseEvent streamingEvent in updates .ToStreamingResponseAsync(EmptyRequest(), context, cancellationToken) .ConfigureAwait(false)) @@ -117,37 +118,35 @@ public static async IAsyncEnumerable WriteResponseStreamAsync( } /// - /// Extracts the continuation/session id candidate from an OpenAI Responses request body. + /// Extracts the id under which the session should be stored from an already-parsed + /// . /// - /// The OpenAI Responses-shaped request body. + /// The parsed request produced by . /// /// The previous_response_id when present; otherwise the conversation id when present; - /// otherwise . Returns for a body that cannot be parsed. + /// otherwise when the request carries neither. /// + /// is . /// - /// This is kept separate from so the - /// trust boundary stays visible: using a request-derived key is an explicit application decision. The returned - /// value is an untrusted candidate key until the application has authorized it for the caller. + /// This reads the ids off the already-parsed request rather than re-parsing the body, so an application + /// calls once and then this. It is kept + /// separate so the trust boundary stays visible: using a request-derived key is an explicit application + /// decision, and the returned value is an untrusted candidate key until the application has + /// authorized it for the caller. A result means only that the request carried no + /// continuation id (unparseable bodies are already rejected by ). /// /// The Responses protocol treats previous_response_id and conversation as mutually exclusive; if a - /// payload sets both, this helper prefers previous_response_id (the response-chain pointer). Note that - /// previous_response_id changes each turn and is therefore not a stable partition key; prefer the - /// conversation id when a stable key is required (for example a workflow checkpoint cursor key). + /// request carries both, this helper prefers previous_response_id (the response-chain pointer). Note that + /// previous_response_id changes each turn and is therefore not a stable partition key; use + /// when a stable key is required (for example a workflow + /// checkpoint cursor key). /// /// - public static string? GetSessionId(JsonElement body) + public static string? GetSessionStoreId(OpenAIResponsesRunRequest request) { - CreateResponse? request; - try - { - request = body.Deserialize(OpenAIHostingJsonContext.Default.CreateResponse); - } - catch (JsonException) - { - return null; - } + ArgumentNullException.ThrowIfNull(request); - return request?.PreviousResponseId ?? request?.Conversation?.Id; + return request.PreviousResponseId ?? request.ConversationId; } /// @@ -156,8 +155,8 @@ public static async IAsyncEnumerable WriteResponseStreamAsync( /// A new response id. public static string CreateResponseId() => IdGenerator.NewId("resp"); - private static AgentInvocationContext CreateContext(string responseId, string? sessionId) - => new(new IdGenerator(responseId, sessionId)); + private static AgentInvocationContext CreateContext(string responseId, string? conversationId) + => new(new IdGenerator(responseId, conversationId)); // The rendering converters never read the request input; a minimal request lets the facade render // a response without requiring the caller to supply the originating request object. diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesRunRequest.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesRunRequest.cs index 8a75fd45044..a77e33cc3b8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesRunRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesRunRequest.cs @@ -16,10 +16,12 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI; /// public sealed class OpenAIResponsesRunRequest { - internal OpenAIResponsesRunRequest(IList messages, AgentRunOptions? options) + internal OpenAIResponsesRunRequest(IList messages, AgentRunOptions? options, string? previousResponseId = null, string? conversationId = null) { this.Messages = messages; this.Options = options; + this.PreviousResponseId = previousResponseId; + this.ConversationId = conversationId; } /// @@ -33,4 +35,16 @@ internal OpenAIResponsesRunRequest(IList messages, AgentRunOptions? /// by default no request setting is mapped. /// public AgentRunOptions? Options { get; } + + /// + /// Gets the request's previous_response_id continuation pointer, or when absent. + /// This changes each turn (it follows the response chain), so it is not a stable partition key. + /// + public string? PreviousResponseId { get; } + + /// + /// Gets the request's conversation id, or when absent. Unlike + /// , this is stable across turns, so it is a valid stable partition key. + /// + public string? ConversationId { get; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs index 04819bc65ef..c9dde199286 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs @@ -36,9 +36,9 @@ public async Task NonStreamingRun_RendersResponsesShapedPayloadAsync() JsonElement body = ParseBody("""{ "input": "Reply with exactly the word: apple" }"""); // Act - string sessionId = OpenAIResponses.GetSessionId(body) ?? OpenAIResponses.CreateResponseId(); OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); - AgentSession session = await state.GetOrCreateSessionAsync(sessionId); + string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); + AgentSession session = await state.GetOrCreateSessionAsync(sessionStoreId); string responseId = OpenAIResponses.CreateResponseId(); AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options); JsonElement payload = OpenAIResponses.WriteResponse(result, responseId, responseId); @@ -60,22 +60,22 @@ public async Task MultiTurn_ContinuesSessionAcrossTurnsAsync() // Act: first turn establishes context, second turn continues from the first response id. string firstResponseId = await RunTurnAsync(agent, state, """{ "input": "Remember the number 7." }"""); JsonElement secondBody = ParseBody($$"""{ "input": "What number did I ask you to remember?", "previous_response_id": "{{firstResponseId}}" }"""); - string secondSessionId = OpenAIResponses.GetSessionId(secondBody)!; OpenAIResponsesRunRequest secondRun = OpenAIResponses.ToAgentRunRequest(secondBody); - AgentSession session = await state.GetOrCreateSessionAsync(secondSessionId); + string secondSessionStoreId = OpenAIResponses.GetSessionStoreId(secondRun)!; + AgentSession session = await state.GetOrCreateSessionAsync(secondSessionStoreId); AgentResponse secondResult = await agent.RunAsync(secondRun.Messages, session, secondRun.Options); // Assert: continuation succeeded and the model produced a textual answer. - Assert.Equal(secondSessionId, firstResponseId); + Assert.Equal(secondSessionStoreId, firstResponseId); Assert.False(string.IsNullOrWhiteSpace(secondResult.Text)); } private static async Task RunTurnAsync(AIAgent agent, HostedAgentState state, string bodyJson) { JsonElement body = ParseBody(bodyJson); - string sessionId = OpenAIResponses.GetSessionId(body) ?? OpenAIResponses.CreateResponseId(); OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); - AgentSession session = await state.GetOrCreateSessionAsync(sessionId); + string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); + AgentSession session = await state.GetOrCreateSessionAsync(sessionStoreId); string responseId = OpenAIResponses.CreateResponseId(); _ = await agent.RunAsync(run.Messages, session, run.Options); await state.SaveSessionAsync(responseId, session); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs index 2712127daa3..f39c2d2f00f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs @@ -162,8 +162,8 @@ private async Task StartAgentHostAsync(IChatClient chatClient) return Results.BadRequest(); } - string sessionId = OpenAIResponses.GetSessionId(body) ?? OpenAIResponses.CreateResponseId(); - AgentSession session = await state.GetOrCreateSessionAsync(sessionId, ct); + string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); + AgentSession session = await state.GetOrCreateSessionAsync(sessionStoreId, ct); string responseId = OpenAIResponses.CreateResponseId(); if (body.TryGetProperty("stream", out JsonElement s) && s.ValueKind == JsonValueKind.True) @@ -204,12 +204,12 @@ private async Task StartWorkflowHostAsync() this._app.MapPost("/responses", async (JsonElement body, CancellationToken ct) => { OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); - string sessionId = GetConversationId(body) ?? OpenAIResponses.CreateResponseId(); + string sessionStoreId = run.ConversationId ?? OpenAIResponses.CreateResponseId(); - HostedWorkflowRunResult result = await state.RunOrResumeAsync(sessionId, run.Messages.ToList(), ct); + HostedWorkflowRunResult result = await state.RunOrResumeAsync(sessionStoreId, run.Messages.ToList(), ct); var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, $"{result.Events.Count} event(s)")); - return Results.Json(OpenAIResponses.WriteResponse(response, OpenAIResponses.CreateResponseId(), sessionId)); + return Results.Json(OpenAIResponses.WriteResponse(response, OpenAIResponses.CreateResponseId(), sessionStoreId)); }); await this._app.StartAsync(); @@ -226,21 +226,6 @@ private HttpClient ResolveTestClient() private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json"); - private static string? GetConversationId(JsonElement body) - { - if (!body.TryGetProperty("conversation", out JsonElement conversation)) - { - return null; - } - - return conversation.ValueKind switch - { - JsonValueKind.String => conversation.GetString(), - JsonValueKind.Object when conversation.TryGetProperty("id", out JsonElement id) => id.GetString(), - _ => null, - }; - } - public async ValueTask DisposeAsync() { this._client?.Dispose(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs index 36885af8f2e..2ed5b735acb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs @@ -28,33 +28,47 @@ public void ToAgentRunRequest_StringInput_ProducesUserMessage() } [Fact] - public void GetSessionId_PreviousResponseId_IsReturned() + public void GetSessionStoreId_PreviousResponseId_IsReturned() { // Arrange using var doc = JsonDocument.Parse("""{ "input": "hi", "previous_response_id": "resp_abc" }"""); + var request = OpenAIResponses.ToAgentRunRequest(doc.RootElement); // Act & Assert - Assert.Equal("resp_abc", OpenAIResponses.GetSessionId(doc.RootElement)); + Assert.Equal("resp_abc", OpenAIResponses.GetSessionStoreId(request)); } [Fact] - public void GetSessionId_ConversationId_IsReturned() + public void GetSessionStoreId_ConversationId_IsReturned() { // Arrange using var doc = JsonDocument.Parse("""{ "input": "hi", "conversation": "conv_xyz" }"""); + var request = OpenAIResponses.ToAgentRunRequest(doc.RootElement); + + // Act & Assert + Assert.Equal("conv_xyz", OpenAIResponses.GetSessionStoreId(request)); + } + + [Fact] + public void GetSessionStoreId_BothPresent_PrefersPreviousResponseId() + { + // Arrange + using var doc = JsonDocument.Parse("""{ "input": "hi", "previous_response_id": "resp_abc", "conversation": "conv_xyz" }"""); + var request = OpenAIResponses.ToAgentRunRequest(doc.RootElement); // Act & Assert - Assert.Equal("conv_xyz", OpenAIResponses.GetSessionId(doc.RootElement)); + Assert.Equal("resp_abc", OpenAIResponses.GetSessionStoreId(request)); } [Fact] - public void GetSessionId_NoContinuationKey_ReturnsNull() + public void GetSessionStoreId_NoContinuationKey_ReturnsNull() { // Arrange using var doc = JsonDocument.Parse("""{ "input": "hi" }"""); + var request = OpenAIResponses.ToAgentRunRequest(doc.RootElement); // Act & Assert - Assert.Null(OpenAIResponses.GetSessionId(doc.RootElement)); + Assert.Null(OpenAIResponses.GetSessionStoreId(request)); } [Fact] @@ -68,13 +82,13 @@ public void ToAgentRunRequest_InvalidBody_ThrowsArgumentException() } [Fact] - public void GetSessionId_MalformedBody_ReturnsNull() + public void ToAgentRunRequest_MalformedBody_ThrowsArgumentException() { // Arrange (an array is not a valid Responses body) using var doc = JsonDocument.Parse("""[ 1, 2, 3 ]"""); // Act & Assert - Assert.Null(OpenAIResponses.GetSessionId(doc.RootElement)); + Assert.Throws("body", () => OpenAIResponses.ToAgentRunRequest(doc.RootElement)); } [Fact] @@ -95,7 +109,7 @@ public void WriteResponse_RendersIdAndOutputText() const string ResponseId = "resp_test123"; // Act - JsonElement payload = OpenAIResponses.WriteResponse(response, ResponseId, sessionId: "conv_1"); + JsonElement payload = OpenAIResponses.WriteResponse(response, ResponseId, conversationId: "conv_1"); // Assert Assert.Equal(ResponseId, payload.GetProperty("id").GetString()); From db98c7e25431723ff1f6cf67f2199cbe9d652a35 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:31:36 +0100 Subject: [PATCH 22/28] Reclaim per-session locks in HostedAgentState and demonstrate session locking in the agent sample --- .../local_responses/Server/Program.cs | 9 +- .../local_responses/Server/README.md | 7 +- .../HostedAgentState.cs | 100 ++++++++++++++++-- .../HostedAgentStateTests.cs | 57 ++++++++++ 4 files changed, 159 insertions(+), 14 deletions(-) diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs index cf677d58709..9b55e4a9a21 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs @@ -47,7 +47,9 @@ static string LookupWeather([Description("The city to look up weather for.")] st tools: [AIFunctionFactory.Create(LookupWeather, name: "lookup_weather")]); // Optional shared execution state: pairs the agent with a session store (in-memory by default). -var state = new HostedAgentState(agent); +// enableSessionLocking makes LockSessionAsync serialize concurrent turns that continue the same session, +// so their get-run-save cycles cannot clobber each other's stored state. +var state = new HostedAgentState(agent, enableSessionLocking: true); var app = builder.Build(); @@ -64,6 +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(); + // Serialize the whole get-run-save cycle for this session so two concurrent turns continuing the same + // session cannot race the read-modify-write. The lock is released when the handler returns (for the + // streaming path, it is held for the duration of the SSE stream, which is intended). + await using IAsyncDisposable sessionLock = await state.LockSessionAsync(sessionStoreId, cancellationToken).ConfigureAwait(false); + AgentSession session = await state.GetOrCreateSessionAsync(sessionStoreId, cancellationToken).ConfigureAwait(false); string responseId = OpenAIResponses.CreateResponseId(); diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md index 0b0b3623471..2ded2587054 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md @@ -11,8 +11,11 @@ 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 `HostedAgentState` over an in-memory `AgentSessionStore`. The agent has a -deterministic `lookup_weather` tool. Binds to `http://localhost:5000` (override with `ASPNETCORE_URLS`). +Session continuity uses `HostedAgentState` over an in-memory `AgentSessionStore`. Session locking is enabled +(`HostedAgentState(agent, enableSessionLocking: true)`), and the route wraps its get-run-save cycle in +`LockSessionAsync(sessionStoreId)` so concurrent turns continuing the same session serialize instead of +racing the stored state. The agent has a deterministic `lookup_weather` tool. Binds to +`http://localhost:5000` (override with `ASPNETCORE_URLS`). ```bash export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs index 1b12a63990c..2781a43e74e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Collections.Concurrent; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -32,7 +32,12 @@ public sealed class HostedAgentState { private readonly AIAgent _agent; private readonly AgentSessionStore _sessionStore; - private readonly ConcurrentDictionary? _sessionLocks; + + // Per-session-store-id mutexes for LockSessionAsync. Guarded by _sessionLocksSync for all reads and + // mutations (including the reference count), so entries can be reclaimed when their last holder releases + // without racing a concurrent acquirer. Null when session locking is disabled. + private readonly Dictionary? _sessionLocks; + private readonly object _sessionLocksSync = new(); /// /// Initializes a new instance of the class. @@ -52,7 +57,7 @@ public HostedAgentState(AIAgent agent, AgentSessionStore? sessionStore = null, b this._agent = agent; this._sessionStore = sessionStore ?? new InMemoryAgentSessionStore(); - this._sessionLocks = enableSessionLocking ? new ConcurrentDictionary(StringComparer.Ordinal) : null; + this._sessionLocks = enableSessionLocking ? new Dictionary(StringComparer.Ordinal) : null; } /// @@ -103,7 +108,9 @@ public ValueTask DeleteSessionAsync(string sessionStoreId, CancellationToken can /// The to monitor for cancellation requests. /// An that releases the lock when disposed. /// - /// When session locking is not enabled, this returns immediately with a no-op releaser. + /// When session locking is not enabled, this returns immediately with a no-op releaser. Otherwise each + /// distinct gets a reference-counted mutex that is removed once its last + /// holder releases, so the lock table does not grow unbounded as new session ids arrive. /// public async ValueTask LockSessionAsync(string sessionStoreId, CancellationToken cancellationToken = default) { @@ -114,19 +121,90 @@ public async ValueTask LockSessionAsync(string sessionStoreId, return NoopReleaser.Instance; } - SemaphoreSlim gate = this._sessionLocks.GetOrAdd(sessionStoreId, static _ => new SemaphoreSlim(1, 1)); - await gate.WaitAsync(cancellationToken).ConfigureAwait(false); - return new SemaphoreReleaser(gate); + // Reserve a reference before waiting so a concurrent releaser cannot reclaim the entry from under us. + SessionLock entry; + lock (this._sessionLocksSync) + { + if (!this._sessionLocks.TryGetValue(sessionStoreId, out entry!)) + { + entry = new SessionLock(); + this._sessionLocks[sessionStoreId] = entry; + } + + entry.RefCount++; + } + + try + { + await entry.Semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + // The wait was cancelled (or faulted) before we held the lock. Drop our reservation and reclaim + // the entry if we were the last referent. + this.ReleaseSessionLock(sessionStoreId, entry, acquired: false); + throw; + } + + return new SessionLockReleaser(this, sessionStoreId, entry); + } + + private void ReleaseSessionLock(string sessionStoreId, SessionLock entry, bool acquired) + { + lock (this._sessionLocksSync) + { + if (acquired) + { + entry.Semaphore.Release(); + } + + if (--entry.RefCount == 0) + { + // No holders or waiters remain (every acquirer bumps RefCount before waiting), so it is safe to + // remove and dispose. A later acquire for the same id simply creates a fresh entry. + this._sessionLocks!.Remove(sessionStoreId); + entry.Semaphore.Dispose(); + } + } + } + + /// + /// Gets the number of active per-session locks currently tracked. Zero when session locking is disabled. + /// + /// Internal test hook used to verify that released locks are reclaimed. + internal int ActiveSessionLockCount + { + get + { + if (this._sessionLocks is null) + { + return 0; + } + + lock (this._sessionLocksSync) + { + return this._sessionLocks.Count; + } + } + } + + private sealed class SessionLock + { + public SemaphoreSlim Semaphore { get; } = new(1, 1); + + // Number of callers that currently hold or are waiting on this lock. Mutated only under + // HostedAgentState._sessionLocksSync. + public int RefCount; } - private sealed class SemaphoreReleaser(SemaphoreSlim gate) : IAsyncDisposable + private sealed class SessionLockReleaser(HostedAgentState owner, string sessionStoreId, SessionLock entry) : IAsyncDisposable { - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "The semaphore is owned by the per-session dictionary and reused across callers; the releaser only releases it.")] - private SemaphoreSlim? _gate = gate; + private HostedAgentState? _owner = owner; public ValueTask DisposeAsync() { - Interlocked.Exchange(ref this._gate, null)?.Release(); + // Release at most once even if the caller disposes more than once. + Interlocked.Exchange(ref this._owner, null)?.ReleaseSessionLock(sessionStoreId, entry, acquired: true); return default; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentStateTests.cs index 2adccb2a145..e413b3918c3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentStateTests.cs @@ -133,5 +133,62 @@ public async Task LockSessionAsync_LockingEnabled_SerializesSameSessionAsync() Assert.False(completedBeforeRelease); } + [Fact] + public async Task LockSessionAsync_ReleasingLastHolder_ReclaimsLockEntryAsync() + { + // Arrange + var state = new HostedAgentState(this._agentMock.Object, this._storeMock.Object, enableSessionLocking: true); + + // Act: acquire and release two different sessions, then acquire/release one again. + var a = await state.LockSessionAsync("session-a"); + var b = await state.LockSessionAsync("session-b"); + Assert.Equal(2, state.ActiveSessionLockCount); + + await a.DisposeAsync(); + await b.DisposeAsync(); + + // Assert: the lock table is emptied once every holder releases, so it does not grow unbounded. + Assert.Equal(0, state.ActiveSessionLockCount); + } + + [Fact] + public async Task LockSessionAsync_ConcurrentHolder_KeepsEntryUntilBothReleaseAsync() + { + // Arrange + var state = new HostedAgentState(this._agentMock.Object, this._storeMock.Object, enableSessionLocking: true); + + // Act: first holder owns the lock; a second acquire for the same id is pending (still a live referent). + var first = await state.LockSessionAsync("session-1"); + var secondTask = state.LockSessionAsync("session-1").AsTask(); + + // Assert: the entry survives while a second referent is waiting. + Assert.Equal(1, state.ActiveSessionLockCount); + + await first.DisposeAsync(); + var second = await secondTask; + Assert.Equal(1, state.ActiveSessionLockCount); + + await second.DisposeAsync(); + Assert.Equal(0, state.ActiveSessionLockCount); + } + + [Fact] + public async Task LockSessionAsync_CancelledAcquire_ReclaimsReservationAsync() + { + // Arrange: hold the lock so a second acquire blocks, then cancel that second acquire. + var state = new HostedAgentState(this._agentMock.Object, this._storeMock.Object, enableSessionLocking: true); + var holder = await state.LockSessionAsync("session-1"); + using var cts = new CancellationTokenSource(); + + // Act + var pending = state.LockSessionAsync("session-1", cts.Token).AsTask(); + cts.Cancel(); + await Assert.ThrowsAnyAsync(() => pending); + + // Assert: the cancelled reservation is dropped; releasing the holder reclaims the entry. + await holder.DisposeAsync(); + Assert.Equal(0, state.ActiveSessionLockCount); + } + private sealed class TestAgentSession : AgentSession; } From fc0b19b2eb8b522532a6a9a5901079158e13e5ab Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:53:12 +0100 Subject: [PATCH 23/28] Internalize per-session locking in HostedAgentState (automatic, on by default) and remove mirroring-Python wording from code and spec --- .../0032-dotnet-hosting-protocol-helpers.md | 11 +- .../003-dotnet-hosting-protocol-helpers.md | 23 ++-- .../local_responses/Server/Program.cs | 11 +- .../local_responses/Server/README.md | 9 +- .../HostedAgentState.cs | 112 +++++------------ .../HostedWorkflowState.cs | 17 ++- .../OpenAIResponsesHostingTests.cs | 4 +- .../BriefWorkflow.cs | 2 +- .../HostedAgentStateTests.cs | 119 +++++++++--------- .../HostedWorkflowStateTests.cs | 6 +- 10 files changed, 130 insertions(+), 184 deletions(-) diff --git a/docs/decisions/0032-dotnet-hosting-protocol-helpers.md b/docs/decisions/0032-dotnet-hosting-protocol-helpers.md index a2a87b606e7..33570ec75c8 100644 --- a/docs/decisions/0032-dotnet-hosting-protocol-helpers.md +++ b/docs/decisions/0032-dotnet-hosting-protocol-helpers.md @@ -52,8 +52,8 @@ A capability comparison of the ADR-0027 / PR #6891 helper surface against the ex | `responses_from_streaming_run` | `AgentResponseUpdateExtensions.ToStreamingResponseAsync` + `SseJsonResult` (also renders workflow events) | exists, internal, richer | | `responses_session_id` | continuity resolved inside `InMemoryResponsesService` | exists, internal, not standalone | | `create_response_id` | `IdGenerator` | exists, internal | -| `AgentState` (target + store, get-or-create) | `AgentSessionStore` (get-or-create + save + serialize + isolation) | richer; missing `Delete` + per-session lock | -| `SessionStore` (get/set/delete) | `AgentSessionStore` + `InMemoryAgentSessionStore` | richer; no `Delete` | +| `AgentState` (target + store, get-or-create) | `AgentSessionStore` (get-or-create + save + serialize + isolation) | richer; internal per-session lock on create-on-miss (matches Python) | +| `SessionStore` (get/set/delete) | `AgentSessionStore` + `InMemoryAgentSessionStore` | richer; `Delete` added | | `WorkflowState` + checkpoint resume | `WorkflowCatalog`/`HostedWorkflowBuilder`; workflow events already render over Responses; `CheckpointManager` is session-keyed | partial; no per-session checkpoint cursor | | App owns routing/auth/middleware/storage | `MapOpenAIResponses`/`IResponsesService` own routing + storage | **the one real gap** | @@ -104,9 +104,10 @@ request object). isolation-decorator passthrough): the one missing store operation. - `HostedAgentState`: a thin holder bundling an `AIAgent` with an `AgentSessionStore`, exposing `GetOrCreateSessionAsync`, `SaveSessionAsync` (including under a newly minted id), and - `DeleteSessionAsync`, with optional per-session locking. It exists because only the holder has both - the target and the store; it does not replace `AgentSessionStore`, which already provides - serialization and isolation that Python's `AgentState`/`SessionStore` lack. + `DeleteSessionAsync`. `GetOrCreateSessionAsync` serializes concurrent first-touch of the same id through + an internal per-session lock (automatic and on by default; callers never manage locking). It exists + because only the holder has both the target and the store; it does not replace `AgentSessionStore`, which + already provides serialization and isolation that Python's `AgentState`/`SessionStore` lack. - `HostedWorkflowState`: a thin holder bundling a workflow target with a `CheckpointManager` and an internal `sessionId -> CheckpointInfo` head cursor, exposing `RunOrResumeAsync`. .NET's checkpoint store is already `sessionId`-keyed (unlike Python's workflow-name keying), but `CheckpointInfo` has diff --git a/docs/specs/003-dotnet-hosting-protocol-helpers.md b/docs/specs/003-dotnet-hosting-protocol-helpers.md index c768a323591..fd85102e44f 100644 --- a/docs/specs/003-dotnet-hosting-protocol-helpers.md +++ b/docs/specs/003-dotnet-hosting-protocol-helpers.md @@ -127,32 +127,31 @@ public sealed class HostedWorkflowState ``` `HostedAgentState.GetOrCreateSessionAsync` delegates to `AgentSessionStore.GetSessionAsync(agent, id)` -(which already creates on miss). `SaveSessionAsync(id, session)` persists post-run, including under a -newly minted `resp_*` id when the protocol mints a new continuation id. Optional per-session locking -serializes concurrent first-touch of the same id. `DeleteSessionAsync` uses the new store method. +(which already creates on miss), serializing concurrent first-touch of the same id through an internal +per-session lock (automatic and on by default; callers never manage it). +`SaveSessionAsync(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. `HostedWorkflowState` defaults to `CheckpointManager.CreateInMemory()` and an in-memory `sessionId -> CheckpointInfo` cursor. Because the checkpoint store is already `sessionId`-keyed but `CheckpointInfo` carries no ordering, the holder remembers the head checkpoint per session so `RunOrResumeAsync` can resume the correct one. On subsequent turns it restores that checkpoint to -rehydrate accumulated workflow state and then runs the workflow forward with the new turn's input — -mirroring the Python hosting host's restore-then-run semantics (`agent_framework_hosting`'s -`_invoke_workflow`), rather than continuing a halted run with no input (which would wait for input +rehydrate accumulated workflow state and then runs the workflow forward with the new turn's input, +rather than continuing a halted run with no input (which would wait for input indefinitely). For agent (chat-protocol) workflows the new input is accompanied by a `TurnToken` so the turn is driven. When the in-memory cursor misses (a new holder or a process restart), the holder falls back to `CheckpointManager.GetLatestCheckpointAsync(sessionId)`, so a durable `CheckpointManager` resumes correctly across restarts (the default in-memory manager does not persist, so a restart starts fresh). A -resume that produces no events is logged as a warning (possible stale checkpoint or mismatched input), -mirroring the Python host's zero-event restore warning. Because a single workflow instance backs the +resume that produces no events is logged as a warning (possible stale checkpoint or mismatched input). +Because a single workflow instance backs the holder and workflow instances do not support concurrent runs, `RunOrResumeAsync` serializes turns through -one lock (mirroring the Python host's workflow lock); `HostedWorkflowState` is therefore `IDisposable`. A +one lock; `HostedWorkflowState` is therefore `IDisposable`. A streaming counterpart, `RunOrResumeStreamingAsync`, yields the turn's `WorkflowEvent`s as they occur (for example to render agent updates over the Responses SSE wire) and records the head checkpoint once the -stream is fully enumerated, keeping the blocking and streaming workflow paths in lockstep as Python does. +stream is fully enumerated, keeping the blocking and streaming workflow paths in lockstep. Because `RunOrResumeAsync`/`RunOrResumeStreamingAsync` are generic over the input type, the application adapts the Responses input into the workflow's start-executor input type at the call site (for example -parsing a structured payload into a typed record) — the .NET counterpart of Python's `ResponsesChannel` -run hook, without coupling the holder to a specific wire type. +parsing a structured payload into a typed record), without coupling the holder to a specific wire type. ## Non-goals for v1 diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs index 9b55e4a9a21..8e4fdd4b7bb 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs @@ -47,9 +47,9 @@ static string LookupWeather([Description("The city to look up weather for.")] st tools: [AIFunctionFactory.Create(LookupWeather, name: "lookup_weather")]); // Optional shared execution state: pairs the agent with a session store (in-memory by default). -// enableSessionLocking makes LockSessionAsync serialize concurrent turns that continue the same session, -// so their get-run-save cycles cannot clobber each other's stored state. -var state = new HostedAgentState(agent, enableSessionLocking: true); +// GetOrCreateSessionAsync serializes concurrent first-touch of the same session internally, so route code +// does not manage any locking. +var state = new HostedAgentState(agent); var app = builder.Build(); @@ -66,11 +66,6 @@ static string LookupWeather([Description("The city to look up weather for.")] st string? candidateSessionStoreId = OpenAIResponses.GetSessionStoreId(run); string sessionStoreId = Authorize(http, candidateSessionStoreId) ?? OpenAIResponses.CreateResponseId(); - // Serialize the whole get-run-save cycle for this session so two concurrent turns continuing the same - // session cannot race the read-modify-write. The lock is released when the handler returns (for the - // streaming path, it is held for the duration of the SSE stream, which is intended). - await using IAsyncDisposable sessionLock = await state.LockSessionAsync(sessionStoreId, cancellationToken).ConfigureAwait(false); - AgentSession session = await state.GetOrCreateSessionAsync(sessionStoreId, cancellationToken).ConfigureAwait(false); string responseId = OpenAIResponses.CreateResponseId(); diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md index 2ded2587054..22771b3b604 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md @@ -11,11 +11,10 @@ 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 `HostedAgentState` over an in-memory `AgentSessionStore`. Session locking is enabled -(`HostedAgentState(agent, enableSessionLocking: true)`), and the route wraps its get-run-save cycle in -`LockSessionAsync(sessionStoreId)` so concurrent turns continuing the same session serialize instead of -racing the stored state. The agent has a deterministic `lookup_weather` tool. Binds to -`http://localhost:5000` (override with `ASPNETCORE_URLS`). +Session continuity uses `HostedAgentState` over an in-memory `AgentSessionStore`. `GetOrCreateSessionAsync` +serializes concurrent first-touch of the same session internally, so the route does not manage any locking. +The agent has a deterministic `lookup_weather` tool. Binds to `http://localhost:5000` (override with +`ASPNETCORE_URLS`). ```bash export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs index 2781a43e74e..d218a6104fd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs @@ -33,10 +33,10 @@ public sealed class HostedAgentState private readonly AIAgent _agent; private readonly AgentSessionStore _sessionStore; - // Per-session-store-id mutexes for LockSessionAsync. Guarded by _sessionLocksSync for all reads and - // mutations (including the reference count), so entries can be reclaimed when their last holder releases - // without racing a concurrent acquirer. Null when session locking is disabled. - private readonly Dictionary? _sessionLocks; + // Per-session-store-id mutexes that serialize create-on-miss inside GetOrCreateSessionAsync. Guarded by + // _sessionLocksSync for all reads and mutations (including the reference count), so entries are reclaimed + // once their last user finishes without racing a concurrent one. + private readonly Dictionary _sessionLocks = new(StringComparer.Ordinal); private readonly object _sessionLocksSync = new(); /// @@ -46,18 +46,13 @@ public sealed class HostedAgentState /// /// The session store to use. Defaults to a fresh when not provided. /// - /// - /// When , serializes access per session id. Defaults - /// to . - /// /// is . - public HostedAgentState(AIAgent agent, AgentSessionStore? sessionStore = null, bool enableSessionLocking = false) + public HostedAgentState(AIAgent agent, AgentSessionStore? sessionStore = null) { _ = Throw.IfNull(agent); this._agent = agent; this._sessionStore = sessionStore ?? new InMemoryAgentSessionStore(); - this._sessionLocks = enableSessionLocking ? new Dictionary(StringComparer.Ordinal) : null; } /// @@ -66,10 +61,26 @@ public HostedAgentState(AIAgent agent, AgentSessionStore? sessionStore = null, b /// The application-selected id under which the session is stored. /// The to monitor for cancellation requests. /// The resolved or newly created . - public ValueTask GetOrCreateSessionAsync(string sessionStoreId, CancellationToken cancellationToken = default) + /// + /// Concurrent calls for the same are serialized internally so create-on-miss + /// stays consistent. The locking is automatic and internal; callers never manage it. + /// + public async ValueTask GetOrCreateSessionAsync(string sessionStoreId, CancellationToken cancellationToken = default) { _ = Throw.IfNullOrEmpty(sessionStoreId); - return this._sessionStore.GetSessionAsync(this._agent, sessionStoreId, cancellationToken); + + SessionLock entry = this.RentSessionLock(sessionStoreId); + bool acquired = false; + try + { + await entry.Semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + acquired = true; + return await this._sessionStore.GetSessionAsync(this._agent, sessionStoreId, cancellationToken).ConfigureAwait(false); + } + finally + { + this.ReleaseSessionLock(sessionStoreId, entry, acquired); + } } /// @@ -100,53 +111,21 @@ public ValueTask DeleteSessionAsync(string sessionStoreId, CancellationToken can return this._sessionStore.DeleteSessionAsync(this._agent, sessionStoreId, cancellationToken); } - /// - /// Acquires an exclusive lock for so concurrent requests for the same - /// session serialize their get-run-save cycle. Dispose the returned value to release the lock. - /// - /// The application-selected id under which the session is stored. - /// The to monitor for cancellation requests. - /// An that releases the lock when disposed. - /// - /// When session locking is not enabled, this returns immediately with a no-op releaser. Otherwise each - /// distinct gets a reference-counted mutex that is removed once its last - /// holder releases, so the lock table does not grow unbounded as new session ids arrive. - /// - public async ValueTask LockSessionAsync(string sessionStoreId, CancellationToken cancellationToken = default) + // Reserves a reference to the per-session lock, creating it on first use. Callers must pair this with + // ReleaseSessionLock. Reference counting lets the entry be reclaimed once no caller holds or waits on it. + private SessionLock RentSessionLock(string sessionStoreId) { - _ = Throw.IfNullOrEmpty(sessionStoreId); - - if (this._sessionLocks is null) - { - return NoopReleaser.Instance; - } - - // Reserve a reference before waiting so a concurrent releaser cannot reclaim the entry from under us. - SessionLock entry; lock (this._sessionLocksSync) { - if (!this._sessionLocks.TryGetValue(sessionStoreId, out entry!)) + if (!this._sessionLocks.TryGetValue(sessionStoreId, out SessionLock? entry)) { entry = new SessionLock(); this._sessionLocks[sessionStoreId] = entry; } entry.RefCount++; + return entry; } - - try - { - await entry.Semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - } - catch - { - // The wait was cancelled (or faulted) before we held the lock. Drop our reservation and reclaim - // the entry if we were the last referent. - this.ReleaseSessionLock(sessionStoreId, entry, acquired: false); - throw; - } - - return new SessionLockReleaser(this, sessionStoreId, entry); } private void ReleaseSessionLock(string sessionStoreId, SessionLock entry, bool acquired) @@ -160,27 +139,23 @@ private void ReleaseSessionLock(string sessionStoreId, SessionLock entry, bool a if (--entry.RefCount == 0) { - // No holders or waiters remain (every acquirer bumps RefCount before waiting), so it is safe to - // remove and dispose. A later acquire for the same id simply creates a fresh entry. - this._sessionLocks!.Remove(sessionStoreId); + // No user holds or waits on this lock (every user bumps RefCount before waiting), so it is safe + // to remove and dispose. A later call for the same id simply creates a fresh entry. This keeps + // the lock table from growing unbounded as new session ids arrive. + this._sessionLocks.Remove(sessionStoreId); entry.Semaphore.Dispose(); } } } /// - /// Gets the number of active per-session locks currently tracked. Zero when session locking is disabled. + /// Gets the number of active per-session locks currently tracked. /// - /// Internal test hook used to verify that released locks are reclaimed. + /// Internal test hook used to verify that per-session locks are reclaimed. internal int ActiveSessionLockCount { get { - if (this._sessionLocks is null) - { - return 0; - } - lock (this._sessionLocksSync) { return this._sessionLocks.Count; @@ -196,23 +171,4 @@ private sealed class SessionLock // HostedAgentState._sessionLocksSync. public int RefCount; } - - private sealed class SessionLockReleaser(HostedAgentState owner, string sessionStoreId, SessionLock entry) : IAsyncDisposable - { - private HostedAgentState? _owner = owner; - - public ValueTask DisposeAsync() - { - // Release at most once even if the caller disposes more than once. - Interlocked.Exchange(ref this._owner, null)?.ReleaseSessionLock(sessionStoreId, entry, acquired: true); - return default; - } - } - - private sealed class NoopReleaser : IAsyncDisposable - { - public static readonly NoopReleaser Instance = new(); - - public ValueTask DisposeAsync() => default; - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs index 61ffdab0b20..8e6a5a4b339 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs @@ -47,7 +47,7 @@ public sealed class HostedWorkflowState : IDisposable private readonly ConcurrentDictionary _cursor = new(StringComparer.Ordinal); // A single workflow instance backs every session on this holder, and workflow instances do not support - // concurrent runs, so all turns are serialized through one lock (mirroring the Python host's workflow lock). + // concurrent runs, so all turns are serialized through one lock. private readonly SemaphoreSlim _workflowLock = new(1, 1); /// @@ -89,10 +89,10 @@ public HostedWorkflowState( /// the new turn's . The new head checkpoint is recorded for the session afterwards. /// /// - /// The resume semantics mirror the Python hosting host (agent_framework_hosting's _invoke_workflow): - /// each turn restores the latest checkpoint to rehydrate accumulated workflow state and then applies the new - /// input, rather than continuing a halted run with no input (which would leave the run waiting for input - /// indefinitely). For agent (chat-protocol) workflows the new input is accompanied by a + /// The resume semantics restore then run: each turn restores the latest checkpoint to rehydrate accumulated + /// workflow state and then applies the new input, rather than continuing a halted run with no input (which + /// would leave the run waiting for input indefinitely). For agent (chat-protocol) workflows the new input is + /// accompanied by a /// so the turn is driven, matching the fresh-run path. /// /// The workflow input type. @@ -126,7 +126,7 @@ private async ValueTask RunOrResumeCoreAsync(st { // The in-memory cursor is empty for this session. Fall back to the checkpoint manager so a durable // manager still resumes after the cursor is lost (for example a process restart or a new holder over - // the same store), mirroring the Python host's per-turn get_latest read-through. + // the same store). head = await this._checkpointManager.GetLatestCheckpointAsync(sessionId, cancellationToken).ConfigureAwait(false); } @@ -274,9 +274,8 @@ private void UpdateCursor(string sessionId, CheckpointInfo? checkpoint) } private void WarnOnNoProgress(string sessionId) - // The resumed turn drove no work. This mirrors the Python host's zero-event restore warning: the - // checkpoint may be stale or the input may not match the workflow's expected type, so the session's - // state may not have progressed. + // The resumed turn drove no work: the checkpoint may be stale or the input may not match the workflow's + // expected type, so the session's state may not have progressed. => this._logger.LogWarning( "Resuming workflow session '{SessionId}' produced no events; the checkpoint may be stale or the input may not match the workflow's expected input type. Session state may not have progressed.", sessionId); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs index f39c2d2f00f..cc7835aec9c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs @@ -23,10 +23,10 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Tests; /// In-process, in-memory hosting tests for : a request travels through an /// app-owned ASP.NET Core route (hosted in-memory via ) that wires /// plus / , -/// exactly like the HostingResponsesAgent / HostingResponsesWorkflow samples. These run fully +/// exactly like the local_responses / local_responses_workflow samples. These run fully /// in-process against a deterministic mock chat client — there is no external server process and no live /// model. Live-model coverage lives in the separate Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests -/// project. This mirrors the Python test_http_round_trip.py coverage. +/// project. /// public sealed class OpenAIResponsesHostingTests : IAsyncDisposable { diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/BriefWorkflow.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/BriefWorkflow.cs index 24ecb003d40..adcbf8f0e87 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/BriefWorkflow.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/BriefWorkflow.cs @@ -10,7 +10,7 @@ namespace Microsoft.Agents.AI.Hosting.UnitTests; /// Builds a workflow whose start executor accepts a typed (not /// List<ChatMessage>) and yields a formatted string. Used to demonstrate that an application can /// adapt Responses input into a workflow's native start-executor input via the generic -/// RunOrResumeAsync<TInput> (the .NET counterpart of Python's ResponsesChannel run hook). +/// RunOrResumeAsync<TInput>. /// internal static class BriefWorkflow { diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentStateTests.cs index e413b3918c3..87fa0a54c8b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentStateTests.cs @@ -101,93 +101,90 @@ public async Task GetOrCreateSessionAsync_InvalidId_ThrowsAsync(string? sessionI } [Fact] - public async Task LockSessionAsync_LockingDisabled_ReturnsNoopReleaserAsync() + public async Task GetOrCreateSessionAsync_SerializesConcurrentSameSessionAsync() { - // Arrange - var state = new HostedAgentState(this._agentMock.Object, this._storeMock.Object); + // Arrange: a store that signals when it is entered and blocks until released. + var store = new GatedStore(); + var state = new HostedAgentState(this._agentMock.Object, store); - // Act - var releaser = await state.LockSessionAsync("session-1"); + // Act: start a first get-or-create and wait until it is inside the store. + var first = state.GetOrCreateSessionAsync("session-1").AsTask(); + await store.EnteredSignal.WaitAsync(); - // Assert (a second acquire does not block when locking is disabled) - var second = await state.LockSessionAsync("session-1"); - await releaser.DisposeAsync(); - await second.DisposeAsync(); - } + // A second get-or-create for the SAME id must not enter the store while the first holds the lock. + var second = state.GetOrCreateSessionAsync("session-1").AsTask(); - [Fact] - public async Task LockSessionAsync_LockingEnabled_SerializesSameSessionAsync() - { - // Arrange - var state = new HostedAgentState(this._agentMock.Object, this._storeMock.Object, enableSessionLocking: true); - var first = await state.LockSessionAsync("session-1"); + // Assert: the second call is blocked (no second entry within the wait window). + Assert.False(await store.EnteredSignal.WaitAsync(TimeSpan.FromMilliseconds(200))); + Assert.Equal(1, store.EnterCount); - // Act: a second acquire for the same session must not complete until the first is released. - var secondTask = state.LockSessionAsync("session-1").AsTask(); - var completedBeforeRelease = secondTask.IsCompleted; - await first.DisposeAsync(); - var second = await secondTask; - await second.DisposeAsync(); + // Release the first; the second then enters and both complete. + store.Release.SetResult(true); + await Task.WhenAll(first, second); + Assert.Equal(2, store.EnterCount); - // Assert - Assert.False(completedBeforeRelease); + // The per-session lock is reclaimed once no caller holds it. + Assert.Equal(0, state.ActiveSessionLockCount); } [Fact] - public async Task LockSessionAsync_ReleasingLastHolder_ReclaimsLockEntryAsync() + public async Task GetOrCreateSessionAsync_DifferentSessions_DoNotBlockEachOtherAsync() { // Arrange - var state = new HostedAgentState(this._agentMock.Object, this._storeMock.Object, enableSessionLocking: true); + var store = new GatedStore(); + var state = new HostedAgentState(this._agentMock.Object, store); - // Act: acquire and release two different sessions, then acquire/release one again. - var a = await state.LockSessionAsync("session-a"); - var b = await state.LockSessionAsync("session-b"); - Assert.Equal(2, state.ActiveSessionLockCount); + // Act: a first call holds session-a inside the store. + var first = state.GetOrCreateSessionAsync("session-a").AsTask(); + await store.EnteredSignal.WaitAsync(); - await a.DisposeAsync(); - await b.DisposeAsync(); + // A call for a DIFFERENT id must not be blocked by the first (locking is per session id). + var second = state.GetOrCreateSessionAsync("session-b").AsTask(); - // Assert: the lock table is emptied once every holder releases, so it does not grow unbounded. + // Assert: the second call enters the store even though the first is still blocked. + Assert.True(await store.EnteredSignal.WaitAsync(TimeSpan.FromSeconds(1))); + Assert.Equal(2, store.EnterCount); + + store.Release.SetResult(true); + await Task.WhenAll(first, second); Assert.Equal(0, state.ActiveSessionLockCount); } [Fact] - public async Task LockSessionAsync_ConcurrentHolder_KeepsEntryUntilBothReleaseAsync() + public async Task GetOrCreateSessionAsync_ReclaimsLockAfterUseAsync() { // Arrange - var state = new HostedAgentState(this._agentMock.Object, this._storeMock.Object, enableSessionLocking: true); - - // Act: first holder owns the lock; a second acquire for the same id is pending (still a live referent). - var first = await state.LockSessionAsync("session-1"); - var secondTask = state.LockSessionAsync("session-1").AsTask(); - - // Assert: the entry survives while a second referent is waiting. - Assert.Equal(1, state.ActiveSessionLockCount); + this._storeMock + .Setup(s => s.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new ValueTask(this._session)); + var state = new HostedAgentState(this._agentMock.Object, this._storeMock.Object); - await first.DisposeAsync(); - var second = await secondTask; - Assert.Equal(1, state.ActiveSessionLockCount); + // Act + _ = await state.GetOrCreateSessionAsync("session-1"); - await second.DisposeAsync(); + // Assert: the lock table does not retain an entry once the call completes. Assert.Equal(0, state.ActiveSessionLockCount); } - [Fact] - public async Task LockSessionAsync_CancelledAcquire_ReclaimsReservationAsync() + private sealed class GatedStore : AgentSessionStore { - // Arrange: hold the lock so a second acquire blocks, then cancel that second acquire. - var state = new HostedAgentState(this._agentMock.Object, this._storeMock.Object, enableSessionLocking: true); - var holder = await state.LockSessionAsync("session-1"); - using var cts = new CancellationTokenSource(); - - // Act - var pending = state.LockSessionAsync("session-1", cts.Token).AsTask(); - cts.Cancel(); - await Assert.ThrowsAnyAsync(() => pending); - - // Assert: the cancelled reservation is dropped; releasing the holder reclaims the entry. - await holder.DisposeAsync(); - Assert.Equal(0, state.ActiveSessionLockCount); + public SemaphoreSlim EnteredSignal { get; } = new(0); + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public int EnterCount; + + public override async ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this.EnterCount); + this.EnteredSignal.Release(); + await this.Release.Task.ConfigureAwait(false); + return new TestAgentSession(); + } + + public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) + => default; + + public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) + => default; } private sealed class TestAgentSession : AgentSession; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs index 781888c3fbe..0e802f451d7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs @@ -137,7 +137,7 @@ public async Task RunOrResumeAsync_ResumeMakesNoProgress_LogsWarningAsync() HostedWorkflowRunResult second = await state.RunOrResumeAsync("s1", 42); // Assert: a resume that produced no events is surfaced as a warning (possible stale checkpoint / - // mismatched input), mirroring the Python host's zero-event restore warning. + // mismatched input). Assert.Empty(second.Events); Assert.Contains(loggerFactory.Entries, e => e.Level == LogLevel.Warning); } @@ -287,8 +287,8 @@ public async Task RunOrResumeStreamingAsync_StreamsEventsAndResumesAsync() public async Task RunOrResumeAsync_AdaptsResponsesInputToTypedStartExecutorAsync() { // Arrange: a workflow whose start executor takes a typed WriterBrief rather than List. - // The application adapts the Responses input into that type before calling RunOrResumeAsync — the - // generic TInput is the .NET counterpart of Python's ResponsesChannel run hook. + // The application adapts the Responses input into that type before calling RunOrResumeAsync via the + // generic TInput. var state = new HostedWorkflowState(BriefWorkflow.Build()); // Simulate parsing a structured Responses text payload into the start executor's input type. From a28a312eb88aebb796d409a5802fbef332c91abf Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:30:36 +0100 Subject: [PATCH 24/28] Remove HostedAgentState; app-owned routes use AgentSessionStore directly HostedAgentState only bundled an AIAgent with an AgentSessionStore and, after the per-session lock was removed, its GetOrCreateSessionAsync/SaveSessionAsync/ DeleteSessionAsync were pass-throughs that just bound the agent argument. Create-on-miss already lives in the store (unlike Python, whose get/set-only SessionStore justifies its AgentState holder), so the type earned its place only via the lock. Each AgentSessionStore.GetSessionAsync now returns an independent session instance per call, so concurrent gets fork the same stored state (e.g. branching from previous_response_id or managing several conversation ids) without sharing an instance. The store does no cross-call locking; serializing concurrent runs against the same id is the application's concern. - Delete HostedAgentState and its unit tests. - Rewire the local_responses sample and the OpenAI hosting unit/integration tests to call AgentSessionStore (GetSessionAsync/SaveSessionAsync) directly. - Update ADR-0032, spec-003, and the af-hosting sample READMEs. --- .../0032-dotnet-hosting-protocol-helpers.md | 15 +- .../003-dotnet-hosting-protocol-helpers.md | 32 ++- .../samples/04-hosting/af-hosting/README.md | 2 +- .../af-hosting/local_responses/README.md | 4 +- .../local_responses/Server/Program.cs | 14 +- .../local_responses/Server/README.md | 5 +- .../HostedAgentState.cs | 174 ---------------- .../OpenAIResponsesHostingLiveTests.cs | 18 +- .../OpenAIResponsesHostingTests.cs | 12 +- .../HostedAgentStateTests.cs | 191 ------------------ 10 files changed, 50 insertions(+), 417 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentStateTests.cs diff --git a/docs/decisions/0032-dotnet-hosting-protocol-helpers.md b/docs/decisions/0032-dotnet-hosting-protocol-helpers.md index 33570ec75c8..6a035379219 100644 --- a/docs/decisions/0032-dotnet-hosting-protocol-helpers.md +++ b/docs/decisions/0032-dotnet-hosting-protocol-helpers.md @@ -102,12 +102,15 @@ request object). - `AgentSessionStore.DeleteSessionAsync(...)` (+ `InMemoryAgentSessionStore` implementation and isolation-decorator passthrough): the one missing store operation. -- `HostedAgentState`: a thin holder bundling an `AIAgent` with an `AgentSessionStore`, exposing - `GetOrCreateSessionAsync`, `SaveSessionAsync` (including under a newly minted id), and - `DeleteSessionAsync`. `GetOrCreateSessionAsync` serializes concurrent first-touch of the same id through - an internal per-session lock (automatic and on by default; callers never manage locking). It exists - because only the holder has both the target and the store; it does not replace `AgentSessionStore`, which - already provides serialization and isolation that Python's `AgentState`/`SessionStore` lack. +- No agent-side holder. Applications use `AgentSessionStore` directly: `GetSessionAsync(agent, id)` + already creates on miss and returns an independent session instance per call (so concurrent calls fork + the same stored state rather than sharing an instance), `SaveSessionAsync(agent, id, session)` persists + post-run (including under a newly minted id), and `DeleteSessionAsync(agent, id)` removes it. An earlier + draft added a `HostedAgentState` holder, but once create-on-miss lives in the store and the store does no + cross-call locking, the holder would only bind the `agent` argument — not enough to justify a public type. + Any coordination for concurrent runs against the same id is the application's concern. (Unlike Python, + whose `SessionStore` is get/set-only and whose `AgentState` therefore owns create-on-miss, .NET's store + already owns it.) - `HostedWorkflowState`: a thin holder bundling a workflow target with a `CheckpointManager` and an internal `sessionId -> CheckpointInfo` head cursor, exposing `RunOrResumeAsync`. .NET's checkpoint store is already `sessionId`-keyed (unlike Python's workflow-name keying), but `CheckpointInfo` has diff --git a/docs/specs/003-dotnet-hosting-protocol-helpers.md b/docs/specs/003-dotnet-hosting-protocol-helpers.md index fd85102e44f..c99417d6258 100644 --- a/docs/specs/003-dotnet-hosting-protocol-helpers.md +++ b/docs/specs/003-dotnet-hosting-protocol-helpers.md @@ -104,16 +104,6 @@ public abstract class AgentSessionStore AIAgent agent, string conversationId, CancellationToken cancellationToken = default); } -// Thin holder: pairs an agent target with a session store. -public sealed class HostedAgentState -{ - public HostedAgentState(AIAgent agent, AgentSessionStore? sessionStore = null); - - public ValueTask GetOrCreateSessionAsync(string sessionId, CancellationToken ct = default); - public ValueTask SaveSessionAsync(string sessionId, AgentSession session, CancellationToken ct = default); - public ValueTask DeleteSessionAsync(string sessionId, CancellationToken ct = default); -} - // Thin holder: pairs a workflow target with checkpointing + a per-session head cursor. public sealed class HostedWorkflowState { @@ -126,11 +116,15 @@ public sealed class HostedWorkflowState } ``` -`HostedAgentState.GetOrCreateSessionAsync` delegates to `AgentSessionStore.GetSessionAsync(agent, id)` -(which already creates on miss), serializing concurrent first-touch of the same id through an internal -per-session lock (automatic and on by default; callers never manage it). -`SaveSessionAsync(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. +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. `HostedWorkflowState` defaults to `CheckpointManager.CreateInMemory()` and an in-memory `sessionId -> CheckpointInfo` cursor. Because the checkpoint store is already `sessionId`-keyed but @@ -176,7 +170,7 @@ parsing a structured payload into a typed record), without coupling the holder t ```csharp var agent = /* an AIAgent */; -var state = new HostedAgentState(agent); // in-memory session store by default +AgentSessionStore sessionStore = new InMemoryAgentSessionStore(); // in-memory session store app.MapPost("/responses", async (HttpContext http, CancellationToken ct) => { @@ -188,7 +182,7 @@ app.MapPost("/responses", async (HttpContext http, CancellationToken ct) => string sessionId = Authorize(http.User, candidate) ?? OpenAIResponses.CreateResponseId(); var run = OpenAIResponses.ToAgentRunRequest(body); - var session = await state.GetOrCreateSessionAsync(sessionId, ct); + var session = await sessionStore.GetSessionAsync(agent, sessionId, ct); string responseId = OpenAIResponses.CreateResponseId(); @@ -201,12 +195,12 @@ app.MapPost("/responses", async (HttpContext http, CancellationToken ct) => await http.Response.WriteAsync(frame, ct); await http.Response.Body.FlushAsync(ct); } - await state.SaveSessionAsync(responseId, session, ct); + await sessionStore.SaveSessionAsync(agent, responseId, session, ct); return Results.Empty; } var result = await agent.RunAsync(run.Messages, session, run.Options, ct); - await state.SaveSessionAsync(responseId, session, ct); + await sessionStore.SaveSessionAsync(agent, responseId, session, ct); return Results.Json(OpenAIResponses.WriteResponse(result, responseId, sessionId)); }); ``` diff --git a/dotnet/samples/04-hosting/af-hosting/README.md b/dotnet/samples/04-hosting/af-hosting/README.md index 5760e154e5f..3e9256204cf 100644 --- a/dotnet/samples/04-hosting/af-hosting/README.md +++ b/dotnet/samples/04-hosting/af-hosting/README.md @@ -23,7 +23,7 @@ Agent Framework gives you two options: | Sample | What it shows | |---|---| -| [`local_responses/`](./local_responses) | An agent behind an ASP.NET Core route you write, using the `OpenAIResponses` helper methods plus `HostedAgentState` / `AgentSessionStore` for conversation continuity. The simplest sample to start with. | +| [`local_responses/`](./local_responses) | An agent behind an ASP.NET Core route you write, using the `OpenAIResponses` helper methods plus `AgentSessionStore` for conversation continuity. The simplest sample to start with. | | [`local_responses_workflow/`](./local_responses_workflow) | A workflow behind an ASP.NET Core route you write, using the `OpenAIResponses` helper methods, `HostedWorkflowState`, an explicit `CheckpointManager`, and a checkpoint cursor your app keeps so a run resumes across turns. | Each sample is a **client/server pair** split into two projects: diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/README.md b/dotnet/samples/04-hosting/af-hosting/local_responses/README.md index 9ed04fcdefc..5f6fb1bd6d4 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/README.md +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/README.md @@ -14,8 +14,8 @@ local_responses/ The server owns routing, authentication, and session storage. The framework provides only the protocol conversion via `OpenAIResponses` (`ToAgentRunRequest`, `GetSessionStoreId`, `WriteResponse` / `WriteResponseStreamAsync`), instead of the batteries-included `MapOpenAIResponses` endpoint. The agent has a -deterministic `lookup_weather` tool. Session continuity uses `HostedAgentState` over an in-memory -`AgentSessionStore`. It binds to `http://localhost:5000`. +deterministic `lookup_weather` tool. Session continuity uses an in-memory `AgentSessionStore` directly. It +binds to `http://localhost:5000`. See [Server/README.md](Server/README.md). diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs index 8e4fdd4b7bb..58c0fcec3f5 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs @@ -46,10 +46,10 @@ static string LookupWeather([Description("The city to look up weather for.")] st name: "WeatherAgent", tools: [AIFunctionFactory.Create(LookupWeather, name: "lookup_weather")]); -// Optional shared execution state: pairs the agent with a session store (in-memory by default). -// GetOrCreateSessionAsync serializes concurrent first-touch of the same session internally, so route code -// does not manage any locking. -var state = new HostedAgentState(agent); +// 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 +// runs concurrent turns against the same session id owns any coordination it needs. +AgentSessionStore sessionStore = new InMemoryAgentSessionStore(); var app = builder.Build(); @@ -66,7 +66,7 @@ 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 state.GetOrCreateSessionAsync(sessionStoreId, cancellationToken).ConfigureAwait(false); + AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId, cancellationToken).ConfigureAwait(false); string responseId = OpenAIResponses.CreateResponseId(); bool stream = body.TryGetProperty("stream", out JsonElement streamProp) && streamProp.ValueKind == JsonValueKind.True; @@ -82,7 +82,7 @@ static string LookupWeather([Description("The city to look up weather for.")] st } // Persist the post-run session under the new response id so the next turn can continue from it. - await state.SaveSessionAsync(responseId, session, cancellationToken).ConfigureAwait(false); + await sessionStore.SaveSessionAsync(agent, responseId, 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. @@ -90,7 +90,7 @@ static string LookupWeather([Description("The city to look up weather for.")] st } AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options, cancellationToken).ConfigureAwait(false); - await state.SaveSessionAsync(responseId, session, cancellationToken).ConfigureAwait(false); + await sessionStore.SaveSessionAsync(agent, responseId, session, cancellationToken).ConfigureAwait(false); return Results.Json(OpenAIResponses.WriteResponse(result, responseId, responseId)); }); diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md index 22771b3b604..c0d8e01df8d 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md @@ -11,8 +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 `HostedAgentState` over an in-memory `AgentSessionStore`. `GetOrCreateSessionAsync` -serializes concurrent first-touch of the same session internally, so the route does not manage any locking. +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. The agent has a deterministic `lookup_weather` tool. Binds to `http://localhost:5000` (override with `ASPNETCORE_URLS`). diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs deleted file mode 100644 index d218a6104fd..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentState.cs +++ /dev/null @@ -1,174 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Hosting; - -/// -/// Optional shared execution state for applications that own their own hosting route and want to reuse -/// Agent Framework session continuity. Pairs a single target with an -/// . -/// -/// -/// -/// This holder exists because only an object that has both the resolved agent target and the session -/// store can offer a target-aware get-or-create. It does not own routing, authentication, middleware, or -/// storage policy; those remain with the application. It does not replace , -/// which already provides serialization and per-principal isolation. -/// -/// -/// Trust boundary. The sessionStoreId values passed to these methods are -/// application-selected partition keys. When a key originates from the wire (for example via -/// OpenAIResponses.GetSessionId(...)), the application must authenticate the caller and authorize -/// the key before using it here. For multi-user hosts, scope the underlying store per principal with -/// . -/// -/// -public sealed class HostedAgentState -{ - private readonly AIAgent _agent; - private readonly AgentSessionStore _sessionStore; - - // Per-session-store-id mutexes that serialize create-on-miss inside GetOrCreateSessionAsync. Guarded by - // _sessionLocksSync for all reads and mutations (including the reference count), so entries are reclaimed - // once their last user finishes without racing a concurrent one. - private readonly Dictionary _sessionLocks = new(StringComparer.Ordinal); - private readonly object _sessionLocksSync = new(); - - /// - /// Initializes a new instance of the class. - /// - /// The agent target used by route code. - /// - /// The session store to use. Defaults to a fresh when not provided. - /// - /// is . - public HostedAgentState(AIAgent agent, AgentSessionStore? sessionStore = null) - { - _ = Throw.IfNull(agent); - - this._agent = agent; - this._sessionStore = sessionStore ?? new InMemoryAgentSessionStore(); - } - - /// - /// Returns the stored session for , creating a new session on first use. - /// - /// The application-selected id under which the session is stored. - /// The to monitor for cancellation requests. - /// The resolved or newly created . - /// - /// Concurrent calls for the same are serialized internally so create-on-miss - /// stays consistent. The locking is automatic and internal; callers never manage it. - /// - public async ValueTask GetOrCreateSessionAsync(string sessionStoreId, CancellationToken cancellationToken = default) - { - _ = Throw.IfNullOrEmpty(sessionStoreId); - - SessionLock entry = this.RentSessionLock(sessionStoreId); - bool acquired = false; - try - { - await entry.Semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - acquired = true; - return await this._sessionStore.GetSessionAsync(this._agent, sessionStoreId, cancellationToken).ConfigureAwait(false); - } - finally - { - this.ReleaseSessionLock(sessionStoreId, entry, acquired); - } - } - - /// - /// Persists under . Call this after the run - /// completes, including under a newly minted continuation id when the protocol mints one. - /// - /// The application-selected id under which the session is stored (may be a newly minted id). - /// The session to persist. - /// The to monitor for cancellation requests. - /// A task representing the asynchronous save operation. - public ValueTask SaveSessionAsync(string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) - { - _ = Throw.IfNullOrEmpty(sessionStoreId); - _ = Throw.IfNull(session); - return this._sessionStore.SaveSessionAsync(this._agent, sessionStoreId, session, cancellationToken); - } - - /// - /// Deletes the stored session for , if present. - /// - /// The application-selected id under which the session is stored. - /// The to monitor for cancellation requests. - /// A task representing the asynchronous delete operation. - /// The underlying store does not support deletion. - public ValueTask DeleteSessionAsync(string sessionStoreId, CancellationToken cancellationToken = default) - { - _ = Throw.IfNullOrEmpty(sessionStoreId); - return this._sessionStore.DeleteSessionAsync(this._agent, sessionStoreId, cancellationToken); - } - - // Reserves a reference to the per-session lock, creating it on first use. Callers must pair this with - // ReleaseSessionLock. Reference counting lets the entry be reclaimed once no caller holds or waits on it. - private SessionLock RentSessionLock(string sessionStoreId) - { - lock (this._sessionLocksSync) - { - if (!this._sessionLocks.TryGetValue(sessionStoreId, out SessionLock? entry)) - { - entry = new SessionLock(); - this._sessionLocks[sessionStoreId] = entry; - } - - entry.RefCount++; - return entry; - } - } - - private void ReleaseSessionLock(string sessionStoreId, SessionLock entry, bool acquired) - { - lock (this._sessionLocksSync) - { - if (acquired) - { - entry.Semaphore.Release(); - } - - if (--entry.RefCount == 0) - { - // No user holds or waits on this lock (every user bumps RefCount before waiting), so it is safe - // to remove and dispose. A later call for the same id simply creates a fresh entry. This keeps - // the lock table from growing unbounded as new session ids arrive. - this._sessionLocks.Remove(sessionStoreId); - entry.Semaphore.Dispose(); - } - } - } - - /// - /// Gets the number of active per-session locks currently tracked. - /// - /// Internal test hook used to verify that per-session locks are reclaimed. - internal int ActiveSessionLockCount - { - get - { - lock (this._sessionLocksSync) - { - return this._sessionLocks.Count; - } - } - } - - private sealed class SessionLock - { - public SemaphoreSlim Semaphore { get; } = new(1, 1); - - // Number of callers that currently hold or are waiting on this lock. Mutated only under - // HostedAgentState._sessionLocksSync. - public int RefCount; - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs index c9dde199286..1aa58c177dc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs @@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests; /// /// Live integration tests for the app-owned routing helper surface ( plus -/// ) exercised against a real OpenAI model. These confirm the crucial +/// ) exercised against a real OpenAI model. These confirm the crucial /// consumption paths — request conversion, an agent run, response rendering, and multi-turn session /// continuity — behave correctly end to end with a real chat client. /// @@ -32,13 +32,13 @@ public async Task NonStreamingRun_RendersResponsesShapedPayloadAsync() // Arrange Assert.SkipWhen(string.IsNullOrEmpty(ApiKey), "OPENAI_API_KEY is not configured; skipping live hosting test."); AIAgent agent = CreateAgent(); - var state = new HostedAgentState(agent); + AgentSessionStore sessionStore = new InMemoryAgentSessionStore(); JsonElement body = ParseBody("""{ "input": "Reply with exactly the word: apple" }"""); // Act OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); - AgentSession session = await state.GetOrCreateSessionAsync(sessionStoreId); + AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId); string responseId = OpenAIResponses.CreateResponseId(); AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options); JsonElement payload = OpenAIResponses.WriteResponse(result, responseId, responseId); @@ -55,14 +55,14 @@ public async Task MultiTurn_ContinuesSessionAcrossTurnsAsync() // Arrange Assert.SkipWhen(string.IsNullOrEmpty(ApiKey), "OPENAI_API_KEY is not configured; skipping live hosting test."); AIAgent agent = CreateAgent(); - var state = new HostedAgentState(agent); + AgentSessionStore sessionStore = new InMemoryAgentSessionStore(); // Act: first turn establishes context, second turn continues from the first response id. - string firstResponseId = await RunTurnAsync(agent, state, """{ "input": "Remember the number 7." }"""); + string firstResponseId = await RunTurnAsync(agent, sessionStore, """{ "input": "Remember the number 7." }"""); JsonElement secondBody = ParseBody($$"""{ "input": "What number did I ask you to remember?", "previous_response_id": "{{firstResponseId}}" }"""); OpenAIResponsesRunRequest secondRun = OpenAIResponses.ToAgentRunRequest(secondBody); string secondSessionStoreId = OpenAIResponses.GetSessionStoreId(secondRun)!; - AgentSession session = await state.GetOrCreateSessionAsync(secondSessionStoreId); + AgentSession session = await sessionStore.GetSessionAsync(agent, secondSessionStoreId); AgentResponse secondResult = await agent.RunAsync(secondRun.Messages, session, secondRun.Options); // Assert: continuation succeeded and the model produced a textual answer. @@ -70,15 +70,15 @@ public async Task MultiTurn_ContinuesSessionAcrossTurnsAsync() Assert.False(string.IsNullOrWhiteSpace(secondResult.Text)); } - private static async Task RunTurnAsync(AIAgent agent, HostedAgentState state, string bodyJson) + private static async Task RunTurnAsync(AIAgent agent, AgentSessionStore sessionStore, string bodyJson) { JsonElement body = ParseBody(bodyJson); OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); - AgentSession session = await state.GetOrCreateSessionAsync(sessionStoreId); + AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId); string responseId = OpenAIResponses.CreateResponseId(); _ = await agent.RunAsync(run.Messages, session, run.Options); - await state.SaveSessionAsync(responseId, session); + await sessionStore.SaveSessionAsync(agent, responseId, session); return responseId; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs index cc7835aec9c..2ed0f705190 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs @@ -22,7 +22,7 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Tests; /// /// In-process, in-memory hosting tests for : a request travels through an /// app-owned ASP.NET Core route (hosted in-memory via ) that wires -/// plus / , +/// plus an / , /// exactly like the local_responses / local_responses_workflow samples. These run fully /// in-process against a deterministic mock chat client — there is no external server process and no live /// model. Live-model coverage lives in the separate Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests @@ -93,7 +93,7 @@ public async Task AgentRoute_MultiTurn_ReusesSessionAcrossTurnsAsync() Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode); Assert.Equal(2, recorder.CallHistory.Count); - // The second call must include the first turn's user message (continuity through HostedAgentState), + // The second call must include the first turn's user message (continuity through the session store), // whereas the first call must not contain the second turn's text. string firstCallText = string.Join("\n", recorder.CallHistory[0].Select(m => m.Text)); string secondCallText = string.Join("\n", recorder.CallHistory[1].Select(m => m.Text)); @@ -148,7 +148,7 @@ private async Task StartAgentHostAsync(IChatClient chatClient) this._app = builder.Build(); var agent = new ChatClientAgent(chatClient, instructions: "You are a helpful assistant.", name: "assistant"); - var state = new HostedAgentState(agent); + AgentSessionStore sessionStore = new InMemoryAgentSessionStore(); this._app.MapPost("/responses", async (JsonElement body, HttpContext http, CancellationToken ct) => { @@ -163,7 +163,7 @@ private async Task StartAgentHostAsync(IChatClient chatClient) } string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); - AgentSession session = await state.GetOrCreateSessionAsync(sessionStoreId, ct); + AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId, ct); string responseId = OpenAIResponses.CreateResponseId(); if (body.TryGetProperty("stream", out JsonElement s) && s.ValueKind == JsonValueKind.True) @@ -175,12 +175,12 @@ private async Task StartAgentHostAsync(IChatClient chatClient) await http.Response.WriteAsync(frame, ct); } - await state.SaveSessionAsync(responseId, session, ct); + await sessionStore.SaveSessionAsync(agent, responseId, session, ct); return Results.Empty; } AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options, ct); - await state.SaveSessionAsync(responseId, session, ct); + await sessionStore.SaveSessionAsync(agent, responseId, session, ct); return Results.Json(OpenAIResponses.WriteResponse(result, responseId, responseId)); }); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentStateTests.cs deleted file mode 100644 index 87fa0a54c8b..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentStateTests.cs +++ /dev/null @@ -1,191 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Threading; -using System.Threading.Tasks; -using Moq; -using Moq.Protected; - -namespace Microsoft.Agents.AI.Hosting.UnitTests; - -/// -/// Unit tests for the class. -/// -public class HostedAgentStateTests -{ - private readonly Mock _agentMock = new(); - private readonly Mock _storeMock = new(); - private readonly AgentSession _session = new TestAgentSession(); - - [Fact] - public void Constructor_NullAgent_Throws() => - // Act & Assert - Assert.Throws("agent", () => new HostedAgentState(null!)); - - [Fact] - public async Task Constructor_NullStore_UsesInMemoryStoreAsync() - { - // Arrange: a null store must fall back to an in-memory store, whose create-on-miss path calls the agent. - this._agentMock - .Protected() - .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) - .Returns(new ValueTask(this._session)); - var state = new HostedAgentState(this._agentMock.Object); - - // Act - var session = await state.GetOrCreateSessionAsync("session-1"); - - // Assert - Assert.Same(this._session, session); - } - - [Fact] - public async Task GetOrCreateSessionAsync_DelegatesToStoreWithAgentAndIdAsync() - { - // Arrange - this._storeMock - .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(this._session); - var state = new HostedAgentState(this._agentMock.Object, this._storeMock.Object); - - // Act - var result = await state.GetOrCreateSessionAsync("session-1"); - - // Assert - Assert.Same(this._session, result); - this._storeMock.Verify(x => x.GetSessionAsync(this._agentMock.Object, "session-1", It.IsAny()), Times.Once); - } - - [Fact] - public async Task SaveSessionAsync_DelegatesToStoreWithAgentAndIdAsync() - { - // Arrange - this._storeMock - .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(ValueTask.CompletedTask); - var state = new HostedAgentState(this._agentMock.Object, this._storeMock.Object); - - // Act - await state.SaveSessionAsync("resp-new", this._session); - - // Assert - this._storeMock.Verify(x => x.SaveSessionAsync(this._agentMock.Object, "resp-new", this._session, It.IsAny()), Times.Once); - } - - [Fact] - public async Task DeleteSessionAsync_DelegatesToStoreWithAgentAndIdAsync() - { - // Arrange - this._storeMock - .Setup(x => x.DeleteSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(ValueTask.CompletedTask); - var state = new HostedAgentState(this._agentMock.Object, this._storeMock.Object); - - // Act - await state.DeleteSessionAsync("session-1"); - - // Assert - this._storeMock.Verify(x => x.DeleteSessionAsync(this._agentMock.Object, "session-1", It.IsAny()), Times.Once); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - public async Task GetOrCreateSessionAsync_InvalidId_ThrowsAsync(string? sessionId) - { - // Arrange - var state = new HostedAgentState(this._agentMock.Object, this._storeMock.Object); - - // Act & Assert - await Assert.ThrowsAnyAsync(() => state.GetOrCreateSessionAsync(sessionId!).AsTask()); - } - - [Fact] - public async Task GetOrCreateSessionAsync_SerializesConcurrentSameSessionAsync() - { - // Arrange: a store that signals when it is entered and blocks until released. - var store = new GatedStore(); - var state = new HostedAgentState(this._agentMock.Object, store); - - // Act: start a first get-or-create and wait until it is inside the store. - var first = state.GetOrCreateSessionAsync("session-1").AsTask(); - await store.EnteredSignal.WaitAsync(); - - // A second get-or-create for the SAME id must not enter the store while the first holds the lock. - var second = state.GetOrCreateSessionAsync("session-1").AsTask(); - - // Assert: the second call is blocked (no second entry within the wait window). - Assert.False(await store.EnteredSignal.WaitAsync(TimeSpan.FromMilliseconds(200))); - Assert.Equal(1, store.EnterCount); - - // Release the first; the second then enters and both complete. - store.Release.SetResult(true); - await Task.WhenAll(first, second); - Assert.Equal(2, store.EnterCount); - - // The per-session lock is reclaimed once no caller holds it. - Assert.Equal(0, state.ActiveSessionLockCount); - } - - [Fact] - public async Task GetOrCreateSessionAsync_DifferentSessions_DoNotBlockEachOtherAsync() - { - // Arrange - var store = new GatedStore(); - var state = new HostedAgentState(this._agentMock.Object, store); - - // Act: a first call holds session-a inside the store. - var first = state.GetOrCreateSessionAsync("session-a").AsTask(); - await store.EnteredSignal.WaitAsync(); - - // A call for a DIFFERENT id must not be blocked by the first (locking is per session id). - var second = state.GetOrCreateSessionAsync("session-b").AsTask(); - - // Assert: the second call enters the store even though the first is still blocked. - Assert.True(await store.EnteredSignal.WaitAsync(TimeSpan.FromSeconds(1))); - Assert.Equal(2, store.EnterCount); - - store.Release.SetResult(true); - await Task.WhenAll(first, second); - Assert.Equal(0, state.ActiveSessionLockCount); - } - - [Fact] - public async Task GetOrCreateSessionAsync_ReclaimsLockAfterUseAsync() - { - // Arrange - this._storeMock - .Setup(s => s.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(new ValueTask(this._session)); - var state = new HostedAgentState(this._agentMock.Object, this._storeMock.Object); - - // Act - _ = await state.GetOrCreateSessionAsync("session-1"); - - // Assert: the lock table does not retain an entry once the call completes. - Assert.Equal(0, state.ActiveSessionLockCount); - } - - private sealed class GatedStore : AgentSessionStore - { - public SemaphoreSlim EnteredSignal { get; } = new(0); - public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - public int EnterCount; - - public override async ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - { - Interlocked.Increment(ref this.EnterCount); - this.EnteredSignal.Release(); - await this.Release.Task.ConfigureAwait(false); - return new TestAgentSession(); - } - - public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) - => default; - - public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - => default; - } - - private sealed class TestAgentSession : AgentSession; -} From 357547c5105955558031bd3ee13fdc80de54201f Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:30:35 +0100 Subject: [PATCH 25/28] Isolate hosted session snapshots and distinguish conversation vs response continuation Mirrors the Python hosted-session isolation work: a hosted session read must be an independent copy, and the app-owned route must persist under the right continuation key depending on how the caller continued the thread. - AgentSessionStore.GetSessionAsync: document the isolation invariant (each call returns an independent AgentSession so concurrent branches from one previous_response_id do not observe each other's mutations or alter stored state); fix the stale "or null if not found" wording (in-box stores return a fresh created session on miss). The in-box stores already satisfy this via a serialize/deserialize snapshot round-trip. - local_responses sample + hosting unit-test route: choose the save key by channel. A stable conversation id is a mutable head (write back under the same id; app owns single-writer coordination). A previous_response_id continuation or first turn is an immutable snapshot (save under the new response id so branches from the same prior response stay independent). - Add regression tests: independent get returns a distinct instance (InMemoryAgentSessionStore); previous_response_id supports independent branches ([1,2,2,3,3]); conversation id advances the mutable head ([1,2]). - Update the sample README and ADR-0032 wording. --- .../0032-dotnet-hosting-protocol-helpers.md | 6 +- .../local_responses/Server/Program.cs | 16 ++++- .../local_responses/Server/README.md | 11 ++++ .../AgentSessionStore.cs | 16 ++++- .../OpenAIResponsesHostingTests.cs | 63 ++++++++++++++++++- .../InMemoryAgentSessionStoreTests.cs | 47 ++++++++++++++ 6 files changed, 149 insertions(+), 10 deletions(-) diff --git a/docs/decisions/0032-dotnet-hosting-protocol-helpers.md b/docs/decisions/0032-dotnet-hosting-protocol-helpers.md index 6a035379219..b26f7856a74 100644 --- a/docs/decisions/0032-dotnet-hosting-protocol-helpers.md +++ b/docs/decisions/0032-dotnet-hosting-protocol-helpers.md @@ -52,7 +52,7 @@ A capability comparison of the ADR-0027 / PR #6891 helper surface against the ex | `responses_from_streaming_run` | `AgentResponseUpdateExtensions.ToStreamingResponseAsync` + `SseJsonResult` (also renders workflow events) | exists, internal, richer | | `responses_session_id` | continuity resolved inside `InMemoryResponsesService` | exists, internal, not standalone | | `create_response_id` | `IdGenerator` | exists, internal | -| `AgentState` (target + store, get-or-create) | `AgentSessionStore` (get-or-create + save + serialize + isolation) | richer; internal per-session lock on create-on-miss (matches Python) | +| `AgentState` (target + store, get-or-create) | `AgentSessionStore` (get-or-create + save + serialize + isolation) | richer; create-on-miss lives in the store, so no separate holder is needed | | `SessionStore` (get/set/delete) | `AgentSessionStore` + `InMemoryAgentSessionStore` | richer; `Delete` added | | `WorkflowState` + checkpoint resume | `WorkflowCatalog`/`HostedWorkflowBuilder`; workflow events already render over Responses; `CheckpointManager` is session-keyed | partial; no per-session checkpoint cursor | | App owns routing/auth/middleware/storage | `MapOpenAIResponses`/`IResponsesService` own routing + storage | **the one real gap** | @@ -138,8 +138,8 @@ persistence happens only after the run completes. Positive: -- Smallest possible surface: the released addition is one facade type plus two thin state holders and - one new store method. +- Smallest possible surface: the released addition is one facade type plus one thin workflow state + holder and one new store method (agents use `AgentSessionStore` directly, no holder). - No duplicated conversion; the app-owned-routing path and the route-owning server share one core. - `MapOpenAIResponses` users are unaffected. diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs index 58c0fcec3f5..b9a62c09dbc 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs @@ -69,6 +69,16 @@ static string LookupWeather([Description("The city to look up weather for.")] st AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId, cancellationToken).ConfigureAwait(false); string responseId = OpenAIResponses.CreateResponseId(); + // Choose where to persist the post-run session, which depends on how the caller continued the thread: + // - A stable "conversation" id is a MUTABLE HEAD: write the advanced session back under the same id so + // the next turn on that conversation sees this turn. Concurrent runs against one conversation id are + // NOT serialized here; a production app must provide its own per-conversation single-writer coordination. + // - Otherwise (a "previous_response_id" continuation or a first turn) the new response id is an IMMUTABLE + // SNAPSHOT: persist under it so a later previous_response_id can branch from this exact point, and two + // branches from the same prior response stay independent. + string? conversationId = run.ConversationId is { Length: > 0 } cid && cid == sessionStoreId ? cid : null; + string saveId = conversationId ?? responseId; + bool stream = body.TryGetProperty("stream", out JsonElement streamProp) && streamProp.ValueKind == JsonValueKind.True; if (stream) @@ -81,8 +91,8 @@ static string LookupWeather([Description("The city to look up weather for.")] st await http.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false); } - // Persist the post-run session under the new response id so the next turn can continue from it. - await sessionStore.SaveSessionAsync(agent, responseId, session, cancellationToken).ConfigureAwait(false); + // Persist the post-run session under the selected continuation id (see saveId above). + await sessionStore.SaveSessionAsync(agent, 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. @@ -90,7 +100,7 @@ static string LookupWeather([Description("The city to look up weather for.")] st } AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options, cancellationToken).ConfigureAwait(false); - await sessionStore.SaveSessionAsync(agent, responseId, session, cancellationToken).ConfigureAwait(false); + await sessionStore.SaveSessionAsync(agent, saveId, session, cancellationToken).ConfigureAwait(false); return Results.Json(OpenAIResponses.WriteResponse(result, responseId, responseId)); }); diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md index c0d8e01df8d..273e22b4e5c 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md @@ -14,6 +14,17 @@ Exposes an `AIAgent` over the OpenAI Responses protocol on a `POST /responses` r 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. + +The route persists each turn under a continuation id chosen by how the caller continued the thread: + +- A stable **`conversation` id is a mutable head**: the advanced session is written back under the same id, + so the next turn on that conversation sees this one. Concurrent runs against a single conversation id are + not serialized by the store; a production app must supply its own per-conversation single-writer + coordination. +- A **`previous_response_id` continuation (or a first turn) is an immutable snapshot**: the session is saved + under the newly minted response id, so a later `previous_response_id` can branch from that exact point and + two branches from the same prior response stay independent. + The agent has a deterministic `lookup_weather` tool. Binds to `http://localhost:5000` (override with `ASPNETCORE_URLS`). diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs index 66ec42951c2..e3d1c20699b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs @@ -68,9 +68,21 @@ public abstract ValueTask SaveSessionAsync( /// The id under which the session is stored. /// The to monitor for cancellation requests. /// - /// A task that represents the asynchronous retrieval operation. - /// The task result contains the serialized session state, or if not found. + /// A task that represents the asynchronous retrieval operation. The task result contains the + /// restored , or a newly created session when nothing is stored for the id. /// + /// + /// Isolation. Each call must return an independent + /// instance. Callers may mutate the returned session, and may run several concurrent branches from the + /// same (for example forking from an OpenAI Responses + /// previous_response_id), without those branches observing one another's mutations or altering the + /// stored state. The in-box stores satisfy this by returning a fresh instance rehydrated from a serialized + /// snapshot on every call; implementations that cache a live must return an + /// independent copy (for example by round-tripping through + /// + /// and ) + /// rather than handing back the shared instance. + /// public abstract ValueTask GetSessionAsync( AIAgent agent, string sessionStoreId, diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs index 2ed0f705190..8baa60598c2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs @@ -30,6 +30,9 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Tests; /// public sealed class OpenAIResponsesHostingTests : IAsyncDisposable { + private static readonly int[] s_independentBranchUserCounts = [1, 2, 2, 3, 3]; + private static readonly int[] s_conversationAdvanceUserCounts = [1, 2]; + private WebApplication? _app; private HttpClient? _client; @@ -101,6 +104,56 @@ public async Task AgentRoute_MultiTurn_ReusesSessionAcrossTurnsAsync() Assert.DoesNotContain("second turn", firstCallText, StringComparison.Ordinal); } + [Fact] + public async Task AgentRoute_PreviousResponseId_SupportsIndependentBranchesAsync() + { + // Arrange: a recording mock so we can count how much history each turn saw. + var recorder = new TestHelpers.ConversationMemoryMockChatClient("ok"); + HttpClient client = await this.StartAgentHostAsync(recorder); + + // Act: a root turn, then two branches from the SAME response id, then a continuation of each branch. + string rootId = await PostAndGetResponseIdAsync(client, """{ "input": "root" }"""); + string branchOneId = await PostAndGetResponseIdAsync(client, $$"""{ "input": "branch one", "previous_response_id": "{{rootId}}" }"""); + string branchTwoId = await PostAndGetResponseIdAsync(client, $$"""{ "input": "branch two", "previous_response_id": "{{rootId}}" }"""); + _ = await PostAndGetResponseIdAsync(client, $$"""{ "input": "continue one", "previous_response_id": "{{branchOneId}}" }"""); + _ = await PostAndGetResponseIdAsync(client, $$"""{ "input": "continue two", "previous_response_id": "{{branchTwoId}}" }"""); + + // Assert: user-message counts per turn are [1, 2, 2, 3, 3]. Both branches build on the root (2) and not + // on each other; each continuation builds only on its own branch (3). Shared/leaked state would instead + // yield an increasing chain such as [1, 2, 3, 4, 5]. + int[] userCounts = recorder.CallHistory + .Select(call => call.Count(m => m.Role == ChatRole.User)) + .ToArray(); + Assert.Equal(s_independentBranchUserCounts, userCounts); + } + + [Fact] + public async Task AgentRoute_ConversationId_AdvancesMutableHeadAcrossTurnsAsync() + { + // Arrange + var recorder = new TestHelpers.ConversationMemoryMockChatClient("ok"); + HttpClient client = await this.StartAgentHostAsync(recorder); + + // Act: two turns on the SAME stable conversation id. + _ = await client.PostAsync(new Uri("/responses", UriKind.Relative), JsonContent("""{ "input": "turn one", "conversation": "conv_stable" }""")); + _ = await client.PostAsync(new Uri("/responses", UriKind.Relative), JsonContent("""{ "input": "turn two", "conversation": "conv_stable" }""")); + + // Assert: the conversation head advanced in place — turn two saw turn one's message (2 user messages), + // rather than resetting to a fresh session (which would show 1). This is the mutable-head write-back. + int[] userCounts = recorder.CallHistory + .Select(call => call.Count(m => m.Role == ChatRole.User)) + .ToArray(); + Assert.Equal(s_conversationAdvanceUserCounts, userCounts); + } + + private static async Task PostAndGetResponseIdAsync(HttpClient client, string body) + { + HttpResponseMessage response = await client.PostAsync(new Uri("/responses", UriKind.Relative), JsonContent(body)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using JsonDocument doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return doc.RootElement.GetProperty("id").GetString()!; + } + [Fact] public async Task AgentRoute_MalformedBody_ReturnsBadRequestAsync() { @@ -166,6 +219,12 @@ private async Task StartAgentHostAsync(IChatClient chatClient) AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId, ct); string responseId = OpenAIResponses.CreateResponseId(); + // A stable conversation id is a mutable head (write back under the same id); a previous_response_id + // continuation or first turn is an immutable snapshot (save under the new response id so branches + // from the same prior response stay independent). + string? conversationId = run.ConversationId is { Length: > 0 } cid && cid == sessionStoreId ? cid : null; + string saveId = conversationId ?? responseId; + if (body.TryGetProperty("stream", out JsonElement s) && s.ValueKind == JsonValueKind.True) { http.Response.ContentType = "text/event-stream"; @@ -175,12 +234,12 @@ private async Task StartAgentHostAsync(IChatClient chatClient) await http.Response.WriteAsync(frame, ct); } - await sessionStore.SaveSessionAsync(agent, responseId, session, ct); + await sessionStore.SaveSessionAsync(agent, saveId, session, ct); return Results.Empty; } AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options, ct); - await sessionStore.SaveSessionAsync(agent, responseId, session, ct); + await sessionStore.SaveSessionAsync(agent, saveId, session, ct); return Results.Json(OpenAIResponses.WriteResponse(result, responseId, responseId)); }); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs index 2e40b351c44..8af3fc43ece 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs @@ -1,9 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.AI; using Moq; using Moq.Protected; @@ -72,6 +74,35 @@ public async Task DeleteSessionAsync_StoreOptsOut_ThrowsNotSupportedAsync() await Assert.ThrowsAsync(() => store.DeleteSessionAsync(new Mock().Object, "any").AsTask()); } + [Fact] + public async Task GetSessionAsync_ReturnsIndependentSnapshot_ForConcurrentBranchesAsync() + { + // Arrange: a real agent so the store round-trips the session through genuine serialize/deserialize, + // and a stored session that carries some state to copy. + AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); + var store = new InMemoryAgentSessionStore(); + + AgentSession original = await agent.CreateSessionAsync(); + original.StateBag.SetValue("marker", "v1"); + await store.SaveSessionAsync(agent, "s1", original); + + // Act: two concurrent branches read the same stored id. + AgentSession branchA = await store.GetSessionAsync(agent, "s1"); + AgentSession branchB = await store.GetSessionAsync(agent, "s1"); + + // Assert: each branch is an independent instance carrying the same content. + Assert.NotSame(branchA, branchB); + Assert.Equal("v1", branchA.StateBag.GetValue("marker")); + Assert.Equal("v1", branchB.StateBag.GetValue("marker")); + + // Mutating one branch must not affect the other branch or the stored snapshot. + branchA.StateBag.SetValue("marker", "mutated"); + Assert.Equal("v1", branchB.StateBag.GetValue("marker")); + + AgentSession branchC = await store.GetSessionAsync(agent, "s1"); + Assert.Equal("v1", branchC.StateBag.GetValue("marker")); + } + private sealed class TestAgentSession : AgentSession; private sealed class ConcreteAgentSessionStore : AgentSessionStore @@ -85,4 +116,20 @@ public override ValueTask GetSessionAsync(AIAgent agent, string se public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } + + // A chat client that is never invoked: these tests only create, serialize, and deserialize sessions. + private sealed class NotInvokedChatClient : IChatClient + { + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + } + } } From 3937c24a4346a6552cbe38d3aabd28694ebebf4e Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:48:20 +0100 Subject: [PATCH 26/28] Add workflow-factory support to HostedWorkflowState for concurrent sessions HostedWorkflowState backed every session with one shared Workflow instance and serialized all turns through a lock, so independent sessions could not run concurrently. Add a workflow-factory constructor and remove the run lock. - New constructor HostedWorkflowState(Func> workflowFactory, ..., bool cacheWorkflow = false): - cacheWorkflow: false (default) builds a fresh instance per run, so independent sessions run in parallel. A resume rehydrates a fresh instance from the session's checkpoint in the shared store. - cacheWorkflow: true builds the workflow once, lazily on first use, and reuses it (a deferred, cached target that, like a shared instance, cannot run concurrent turns). - Remove the internal SemaphoreSlim run lock and IDisposable; the instance constructor is unchanged in behaviour (one shared instance still cannot run concurrent turns). Turns are no longer serialized by the holder; a single writer per session is the application's responsibility. - Switch the local_responses_workflow sample to the factory constructor with an explicit cacheWorkflow: false, and document the option. - Add tests: parallel independent sessions (factory), fresh-instance resume, cached factory builds once and reuses, uncached factory builds per run. - Update ADR-0032, spec-003, and the sample README. --- .../0032-dotnet-hosting-protocol-helpers.md | 9 +- .../003-dotnet-hosting-protocol-helpers.md | 17 +- .../Server/Program.cs | 33 ++- .../local_responses_workflow/Server/README.md | 9 + .../HostedWorkflowState.cs | 221 ++++++++++++------ .../HostedWorkflowStateTests.cs | 105 +++++++-- 6 files changed, 280 insertions(+), 114 deletions(-) diff --git a/docs/decisions/0032-dotnet-hosting-protocol-helpers.md b/docs/decisions/0032-dotnet-hosting-protocol-helpers.md index b26f7856a74..987447c5d94 100644 --- a/docs/decisions/0032-dotnet-hosting-protocol-helpers.md +++ b/docs/decisions/0032-dotnet-hosting-protocol-helpers.md @@ -118,7 +118,14 @@ request object). restores that checkpoint and runs the workflow forward with the new turn's input (mirroring the Python host's restore-then-run semantics), rather than continuing a halted run with no input. When the in-memory cursor misses (new holder / process restart) it reads the session's latest checkpoint from the - `CheckpointManager`, so a durable manager resumes across restarts. + `CheckpointManager`, so a durable manager resumes across restarts. It accepts either a single workflow + instance (which cannot be run by two runners at once, so its turns are processed one at a time) or a + workflow factory (`Func>`). By default the factory builds a fresh + instance per run so independent sessions run in parallel; with `cacheWorkflow: true` the factory is invoked + once lazily and its result is cached and reused (a deferred, cached target that, like the instance, cannot + run concurrent turns). A resume rehydrates an instance from the session's checkpoint in the shared store, so + per-run instances still continue the same run; concurrent turns against the same session id remain the + application's coordination responsibility. ### Scope diff --git a/docs/specs/003-dotnet-hosting-protocol-helpers.md b/docs/specs/003-dotnet-hosting-protocol-helpers.md index c99417d6258..158d0215195 100644 --- a/docs/specs/003-dotnet-hosting-protocol-helpers.md +++ b/docs/specs/003-dotnet-hosting-protocol-helpers.md @@ -107,8 +107,13 @@ public abstract class AgentSessionStore // Thin holder: pairs a workflow target with checkpointing + a per-session head cursor. public sealed class HostedWorkflowState { + // Shared-instance mode: one instance cannot be run by two runners at once, so turns run one at a time. public HostedWorkflowState(Workflow workflow, CheckpointManager? checkpointManager = null); + // Factory mode: by default a fresh instance is built per run, so independent sessions run in parallel. + // With cacheWorkflow: true the factory is invoked once lazily and the built instance is cached and reused. + public HostedWorkflowState(Func> workflowFactory, CheckpointManager? checkpointManager = null, bool cacheWorkflow = false); + // First turn runs forward from the start; subsequent turns restore the session's latest // checkpoint and run forward with the new turn's input, then record the new head checkpoint. public ValueTask RunOrResumeAsync( @@ -137,9 +142,15 @@ turn is driven. When the in-memory cursor misses (a new holder or a process rest back to `CheckpointManager.GetLatestCheckpointAsync(sessionId)`, so a durable `CheckpointManager` resumes correctly across restarts (the default in-memory manager does not persist, so a restart starts fresh). A resume that produces no events is logged as a warning (possible stale checkpoint or mismatched input). -Because a single workflow instance backs the -holder and workflow instances do not support concurrent runs, `RunOrResumeAsync` serializes turns through -one lock; `HostedWorkflowState` is therefore `IDisposable`. A +Concurrency depends on how the holder is constructed. With a single shared workflow instance, concurrent runs +are not supported, because a workflow instance cannot be run by two runners at once; process turns one at a +time. With a workflow factory +(`Func>`) it builds a fresh instance per run by default, so independent +sessions run in parallel; a resume rehydrates a fresh instance +from the session's checkpoint in the shared store, and concurrent turns against the same session id remain the +application's coordination responsibility. Passing `cacheWorkflow: true` instead builds the workflow once, +lazily on first use, and reuses it (a deferred, cached target that — like the instance — cannot run concurrent +turns). A streaming counterpart, `RunOrResumeStreamingAsync`, yields the turn's `WorkflowEvent`s as they occur (for example to render agent updates over the Responses SSE wire) and records the head checkpoint once the stream is fully enumerated, keeping the blocking and streaming workflow paths in lockstep. diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/Program.cs b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/Program.cs index 4065fc0bd98..7f45c15f192 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/Program.cs +++ b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/Program.cs @@ -38,18 +38,27 @@ // Workflow shape: a brief adapter turns the Responses input into the writer's // prompt and drives the agent turn, then a formatter renders the writer's output as a single slogan line. -var briefExecutor = new BriefExecutor(); -var formatterExecutor = new SloganFormatterExecutor(); - -Workflow workflow = new WorkflowBuilder(briefExecutor) - .AddEdge(briefExecutor, writer) - .AddEdge(writer, formatterExecutor) - .WithOutputFrom(formatterExecutor) - .Build(); - -// Optional shared execution state: pairs the workflow with an in-memory CheckpointManager and a per-session -// sessionId -> CheckpointInfo head cursor so a session can resume from its last checkpoint. -var state = new HostedWorkflowState(workflow); +// A factory builds a fresh workflow instance per run so independent sessions can run concurrently. +static Workflow BuildWorkflow(AIAgent writer) +{ + var briefExecutor = new BriefExecutor(); + var formatterExecutor = new SloganFormatterExecutor(); + + return new WorkflowBuilder(briefExecutor) + .AddEdge(briefExecutor, writer) + .AddEdge(writer, formatterExecutor) + .WithOutputFrom(formatterExecutor) + .Build(); +} + +// Optional shared execution state: the factory constructor builds a fresh workflow instance per run (the +// default, cacheWorkflow: false, shown explicitly here), so independent sessions run in parallel — a single +// shared instance cannot run concurrent turns. Pass cacheWorkflow: true instead to build the workflow once, +// lazily on first use, and reuse it (a deferred, cached target that, like a shared instance, cannot run +// concurrent turns). It is paired with an in-memory CheckpointManager and a per-session +// sessionId -> CheckpointInfo head cursor so a session can resume from its last checkpoint; a resume rehydrates +// a fresh instance from that shared checkpoint store. +var state = new HostedWorkflowState(_ => new ValueTask(BuildWorkflow(writer)), cacheWorkflow: false); // The app keeps a response-id -> workflow-session-id cursor. previous_response_id rotates each turn, so every id in // a conversation's chain maps to the same workflow session, and resuming any of them restores that session's diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/README.md b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/README.md index 2d372da9bec..3fd92e18a4f 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/README.md +++ b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/README.md @@ -13,6 +13,15 @@ response id to the stable workflow session id, so the whole rotating chain resum run. The first turn runs the workflow forward; later turns restore the latest checkpoint and run forward with the new brief. Binds to `http://localhost:5001` (override with `ASPNETCORE_URLS`). +`HostedWorkflowState` is constructed with a **workflow factory** and `cacheWorkflow: false` (the default, +shown explicitly), so it builds a fresh workflow instance for every run. This lets independent sessions run +concurrently — a single shared `Workflow` instance permits only one active run, so the instance constructor +cannot process turns concurrently. A resume rehydrates a fresh instance from the session's checkpoint in the +shared `CheckpointManager`, so per-run instances still continue the same run. (Passing `cacheWorkflow: true` +would instead build the workflow once, lazily, and reuse it — a deferred, cached target that, like a shared +instance, cannot run concurrent turns.) Concurrent turns against the *same* session id are the application's +responsibility; a production app owns that per-session single-writer coordination. + ```bash export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" export FOUNDRY_MODEL="gpt-5.4-mini" diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs index 8e6a5a4b339..69a17b71fb3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs @@ -38,20 +38,23 @@ namespace Microsoft.Agents.AI.Hosting; /// checkpoint boundary must be at least as specific as the authorized session boundary. /// /// -public sealed class HostedWorkflowState : IDisposable +public sealed class HostedWorkflowState { private readonly CheckpointManager _checkpointManager; private readonly IWorkflowExecutionEnvironment _executionEnvironment; - private readonly Workflow _workflow; + private readonly Workflow? _workflow; + private readonly Func>? _workflowFactory; + // Cached-factory mode: the factory runs once, on first use, guarded by _cacheSync, and the built workflow task + // is reused for every run thereafter. + private readonly bool _cacheWorkflow; + private readonly object _cacheSync = new(); + private Task? _cachedWorkflowTask; private readonly ILogger _logger; private readonly ConcurrentDictionary _cursor = new(StringComparer.Ordinal); - // A single workflow instance backs every session on this holder, and workflow instances do not support - // concurrent runs, so all turns are serialized through one lock. - private readonly SemaphoreSlim _workflowLock = new(1, 1); - /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class over a single shared workflow + /// instance. /// /// The workflow target. /// @@ -69,6 +72,13 @@ public sealed class HostedWorkflowState : IDisposable /// Defaults to when not provided. /// /// is . + /// + /// A single workflow instance cannot be run by two runners at once, so concurrent runs against this holder are + /// not supported; process turns one at a time. To run independent sessions concurrently, use the factory + /// constructor + /// (), + /// which builds a fresh workflow instance per run. + /// public HostedWorkflowState( Workflow workflow, CheckpointManager? checkpointManager = null, @@ -83,6 +93,87 @@ public HostedWorkflowState( this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(typeof(HostedWorkflowState)); } + /// + /// Initializes a new instance of the class that builds its workflow from a + /// factory. + /// + /// + /// A factory that produces a workflow instance. Every produced instance must have the same executor topology, + /// because a resume rehydrates an instance from the session's checkpoint in the shared + /// . By default ( is ) + /// it is invoked once per run, so independent sessions each get their own instance and run concurrently. When + /// is it is invoked once, on first use, and the built + /// instance is reused for every run. + /// + /// + /// The checkpoint manager to use. Defaults to when not provided. + /// + /// + /// The workflow execution environment used to run and resume the workflow. Defaults to an in-process environment + /// () configured with . A supplied + /// environment must checkpoint into the same store as , since the holder reads + /// that manager directly to recover the head checkpoint when its in-memory cursor misses. + /// + /// + /// The logger factory used to report resume diagnostics (for example, a resume turn that made no progress). + /// Defaults to when not provided. + /// + /// + /// When (the default), the factory is invoked once per run, so independent sessions run + /// in parallel. When , the factory is invoked once, lazily on first use, and the built + /// workflow is cached and reused for every run — a deferred, cached target. Because that reuses a single + /// instance (which cannot be run by two runners at once), a cached workflow's turns cannot run concurrently, + /// exactly like the instance constructor. The cached build uses because the + /// single built instance is shared across runs and must not be tied to one request's cancellation. + /// + /// is . + /// + /// With the default (uncached) factory, turns are not serialized: independent sessions run in parallel, and + /// concurrent turns against the same session id are not serialized either — an application that needs a + /// single writer per session owns that coordination. + /// + public HostedWorkflowState( + Func> workflowFactory, + CheckpointManager? checkpointManager = null, + IWorkflowExecutionEnvironment? executionEnvironment = null, + ILoggerFactory? loggerFactory = null, + bool cacheWorkflow = false) + { + _ = Throw.IfNull(workflowFactory); + + this._workflowFactory = workflowFactory; + this._cacheWorkflow = cacheWorkflow; + + this._checkpointManager = checkpointManager ?? CheckpointManager.CreateInMemory(); + this._executionEnvironment = executionEnvironment ?? InProcessExecution.Default.WithCheckpointing(this._checkpointManager); + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(typeof(HostedWorkflowState)); + } + + // Resolves the workflow for a turn: the shared instance in instance mode; the cached instance in cached-factory + // mode (built once on first use); or a fresh instance from the factory in the default (uncached) factory mode. + private async ValueTask ResolveWorkflowAsync(CancellationToken cancellationToken) + { + if (this._workflow is not null) + { + return this._workflow; + } + + if (!this._cacheWorkflow) + { + return await this._workflowFactory!(cancellationToken).ConfigureAwait(false); + } + + // Cached factory: build the workflow once. The lock guards only the one-time task assignment (and the + // factory's synchronous prefix); the actual build is awaited outside the lock. CancellationToken.None is + // used because the single built instance is shared across runs and must not be tied to one request. + lock (this._cacheSync) + { + this._cachedWorkflowTask ??= this._workflowFactory!(CancellationToken.None).AsTask(); + } + + return await this._cachedWorkflowTask.ConfigureAwait(false); + } + /// /// Runs the workflow forward for with checkpointing on the first turn, or, on /// subsequent turns, restores the session's recorded head checkpoint and then runs the workflow forward with @@ -106,22 +197,14 @@ public async ValueTask RunOrResumeAsync(string _ = Throw.IfNullOrEmpty(sessionId); _ = Throw.IfNull(input); - // Serialize turns: the shared workflow instance cannot be run by two runners at once, and concurrent - // same-session turns would otherwise race the head cursor. - await this._workflowLock.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - return await this.RunOrResumeCoreAsync(sessionId, input, cancellationToken).ConfigureAwait(false); - } - finally - { - this._workflowLock.Release(); - } + return await this.RunOrResumeCoreAsync(sessionId, input, cancellationToken).ConfigureAwait(false); } private async ValueTask RunOrResumeCoreAsync(string sessionId, TInput input, CancellationToken cancellationToken) where TInput : notnull { + Workflow workflow = await this.ResolveWorkflowAsync(cancellationToken).ConfigureAwait(false); + if (!this._cursor.TryGetValue(sessionId, out CheckpointInfo? head)) { // The in-memory cursor is empty for this session. Fall back to the checkpoint manager so a durable @@ -133,7 +216,7 @@ private async ValueTask RunOrResumeCoreAsync(st if (head is null) { // First turn for this session: run the workflow forward from its start executor with the input. - Run freshRun = await this._executionEnvironment.RunAsync(this._workflow, input, sessionId, cancellationToken).ConfigureAwait(false); + Run freshRun = await this._executionEnvironment.RunAsync(workflow, input, sessionId, cancellationToken).ConfigureAwait(false); await using (freshRun.ConfigureAwait(false)) { return this.Record(sessionId, freshRun.OutgoingEvents.ToList(), freshRun.LastCheckpoint); @@ -146,9 +229,9 @@ private async ValueTask RunOrResumeCoreAsync(st // // The streaming resume restores state without draining to a halt first; the non-streaming resume would // block waiting for input immediately after restore (before we can deliver the new input). - ProtocolDescriptor descriptor = await this._workflow.DescribeProtocolAsync(cancellationToken).ConfigureAwait(false); + ProtocolDescriptor descriptor = await workflow.DescribeProtocolAsync(cancellationToken).ConfigureAwait(false); - StreamingRun resumed = await this._executionEnvironment.ResumeStreamingAsync(this._workflow, head, cancellationToken).ConfigureAwait(false); + StreamingRun resumed = await this._executionEnvironment.ResumeStreamingAsync(workflow, head, cancellationToken).ConfigureAwait(false); await using (resumed.ConfigureAwait(false)) { await resumed.TrySendMessageAsync(input).ConfigureAwait(false); @@ -183,10 +266,9 @@ private async ValueTask RunOrResumeCoreAsync(st /// including when the consumer abandons enumeration early. /// /// - /// Turns are serialized through the holder's workflow lock, which is held for the lifetime of the returned - /// enumerator. The caller must enumerate the stream to completion (or dispose it) to release the lock. The head - /// checkpoint is recorded from the run's last committed checkpoint when the stream ends — whether it completes - /// normally or the consumer disposes it early — so an interrupted turn still advances the session cursor. + /// The head checkpoint is recorded from the run's last committed checkpoint when the stream ends — whether it + /// completes normally or the consumer disposes it early — so an interrupted turn still advances the session + /// cursor. /// /// The workflow input type. /// The application-selected session id. @@ -199,63 +281,57 @@ public async IAsyncEnumerable RunOrResumeStreamingAsync(s _ = Throw.IfNullOrEmpty(sessionId); _ = Throw.IfNull(input); - await this._workflowLock.WaitAsync(cancellationToken).ConfigureAwait(false); - try + Workflow workflow = await this.ResolveWorkflowAsync(cancellationToken).ConfigureAwait(false); + + if (!this._cursor.TryGetValue(sessionId, out CheckpointInfo? head)) { - if (!this._cursor.TryGetValue(sessionId, out CheckpointInfo? head)) - { - head = await this._checkpointManager.GetLatestCheckpointAsync(sessionId, cancellationToken).ConfigureAwait(false); - } + head = await this._checkpointManager.GetLatestCheckpointAsync(sessionId, cancellationToken).ConfigureAwait(false); + } - ProtocolDescriptor descriptor = await this._workflow.DescribeProtocolAsync(cancellationToken).ConfigureAwait(false); + ProtocolDescriptor descriptor = await workflow.DescribeProtocolAsync(cancellationToken).ConfigureAwait(false); - // The fresh streaming run enqueues the input itself; the streaming resume restores state and needs the - // input delivered explicitly. Neither streaming entry point seeds a TurnToken, so drive chat-protocol - // workflows with one on both paths. - StreamingRun run = head is null - ? await this._executionEnvironment.RunStreamingAsync(this._workflow, input, sessionId, cancellationToken).ConfigureAwait(false) - : await this._executionEnvironment.ResumeStreamingAsync(this._workflow, head, cancellationToken).ConfigureAwait(false); + // The fresh streaming run enqueues the input itself; the streaming resume restores state and needs the + // input delivered explicitly. Neither streaming entry point seeds a TurnToken, so drive chat-protocol + // workflows with one on both paths. + StreamingRun run = head is null + ? await this._executionEnvironment.RunStreamingAsync(workflow, input, sessionId, cancellationToken).ConfigureAwait(false) + : await this._executionEnvironment.ResumeStreamingAsync(workflow, head, cancellationToken).ConfigureAwait(false); - await using (run.ConfigureAwait(false)) + await using (run.ConfigureAwait(false)) + { + if (head is not null) { - if (head is not null) - { - await run.TrySendMessageAsync(input).ConfigureAwait(false); - } + await run.TrySendMessageAsync(input).ConfigureAwait(false); + } - if (descriptor.IsChatProtocol() && input is not TurnToken) - { - await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); - } + if (descriptor.IsChatProtocol() && input is not TurnToken) + { + await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); + } - int eventCount = 0; - try + int eventCount = 0; + try + { + // Drain non-blocking on pending requests (see RunOrResumeCoreAsync) so a workflow that halts + // awaiting an external response ends the stream instead of blocking indefinitely. + await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken).ConfigureAwait(false)) { - // Drain non-blocking on pending requests (see RunOrResumeCoreAsync) so a workflow that halts - // awaiting an external response ends the stream instead of blocking indefinitely. - await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken).ConfigureAwait(false)) - { - eventCount++; - yield return evt; - } - - if (eventCount == 0 && head is not null) - { - this.WarnOnNoProgress(sessionId); - } + eventCount++; + yield return evt; } - finally + + if (eventCount == 0 && head is not null) { - // Record the head checkpoint even when the consumer abandons the stream (for example an SSE - // client disconnect), so an interrupted turn still advances the session cursor to the last - // committed checkpoint and a later turn resumes from there rather than re-running prior work. - this.UpdateCursor(sessionId, run.LastCheckpoint); + this.WarnOnNoProgress(sessionId); } } - } - finally - { - this._workflowLock.Release(); + finally + { + // Record the head checkpoint even when the consumer abandons the stream (for example an SSE + // client disconnect), so an interrupted turn still advances the session cursor to the last + // committed checkpoint and a later turn resumes from there rather than re-running prior work. + this.UpdateCursor(sessionId, run.LastCheckpoint); + } } } @@ -294,9 +370,4 @@ internal bool TryGetCheckpoint(string sessionId, out CheckpointInfo? checkpoint) _ = Throw.IfNullOrEmpty(sessionId); return this._cursor.TryGetValue(sessionId, out checkpoint); } - - /// - /// Releases the resources used by this instance, including the workflow serialization lock. - /// - public void Dispose() => this._workflowLock.Dispose(); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs index 0e802f451d7..fa3266be698 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs @@ -21,7 +21,7 @@ public class HostedWorkflowStateTests [Fact] public void Constructor_NullWorkflow_Throws() => // Act & Assert - Assert.Throws("workflow", () => new HostedWorkflowState(null!)); + Assert.Throws("workflow", () => new HostedWorkflowState((Workflow)null!)); [Fact] public void TryGetCheckpoint_UnknownSession_ReturnsFalse() @@ -166,38 +166,97 @@ public async Task RunOrResumeAsync_CursorMiss_ResumesFromManagerLatestCheckpoint } [Fact] - public async Task RunOrResumeAsync_ConcurrentSameSessionTurns_AreSerializedAsync() + public void Constructor_NullFactory_Throws() => + // Act & Assert + Assert.Throws("workflowFactory", () => new HostedWorkflowState((Func>)null!)); + + [Fact] + public async Task RunOrResumeAsync_Factory_ConcurrentDifferentSessions_RunInParallelAsync() { - // Arrange: a workflow that signals when a turn enters and then blocks on a gate, so the test can - // hold the first turn "inside" the workflow while it starts a second turn for the same session. + // Arrange: factory mode builds a fresh workflow instance per run, so independent sessions are NOT + // serialized. The gated workflow signals on entry and blocks on a shared gate; both instances share the + // same gate so the test can hold both turns "inside" the workflow at once. using var entered = new SemaphoreSlim(0, 2); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var state = new HostedWorkflowState(GatedCountingWorkflow.Build(entered, release.Task), CheckpointManager.CreateInMemory()); + var state = new HostedWorkflowState( + _ => new ValueTask(GatedCountingWorkflow.Build(entered, release.Task)), + CheckpointManager.CreateInMemory()); - // Act: start the first turn and wait until it is running inside the workflow. + // Act: start two turns for DIFFERENT sessions. Task first = state.RunOrResumeAsync("s1", "go").AsTask(); - Assert.True(await entered.WaitAsync(TimeSpan.FromSeconds(10)), "the first turn should enter the workflow"); + Task second = state.RunOrResumeAsync("s2", "go").AsTask(); - // Start a second same-session turn while the first is still inside the workflow. - Task second = state.RunOrResumeAsync("s1", "go").AsTask(); - - // Assert: the second turn must WAIT on the holder lock, not fault. Without the lock it would reach the - // engine's concurrent-run ownership guard and fault (completing the task); the lock instead leaves it - // pending until the first turn releases. Checking the task is not completed isolates the holder lock - // from the engine guard, and the entered gate confirms it did not run concurrently. - await Task.Delay(TimeSpan.FromMilliseconds(500)); - Assert.False(second.IsCompleted, "the second turn must wait on the holder lock rather than fault or run concurrently"); - Assert.False(await entered.WaitAsync(TimeSpan.FromMilliseconds(200)), "the second turn must not enter the workflow while the first holds it"); + // Assert: BOTH turns enter the workflow before either is released — proving they run in parallel. In + // shared-instance mode the second would wait on the holder lock and this second wait would time out. + Assert.True(await entered.WaitAsync(TimeSpan.FromSeconds(10)), "the first turn should enter the workflow"); + Assert.True(await entered.WaitAsync(TimeSpan.FromSeconds(10)), "the second turn should enter concurrently in factory mode"); - // Release both turns and let them run to completion. + // Release both and let them complete. release.SetResult(); HostedWorkflowRunResult[] results = await Task.WhenAll(first, second).WaitAsync(TimeSpan.FromSeconds(30)); - // Both turns completed successfully (proving the lock serialized rather than faulted them), advancing - // the count from 1 to 2. - string combined = string.Concat(results.Select(StringOutput)); - Assert.Contains("count:1", combined); - Assert.Contains("count:2", combined); + // Each independent session produced its own first-turn count. + Assert.All(results, r => Assert.Contains("count:1", StringOutput(r))); + } + + [Fact] + public async Task RunOrResumeAsync_Factory_FirstTurnThenResume_AdvancesCheckpointAsync() + { + // Arrange: factory mode with a fresh instance per run. A resume must rehydrate a fresh instance from the + // session's checkpoint in the shared manager. + var state = new HostedWorkflowState(_ => new ValueTask(CountingWorkflow.Build())); + + // Act: two turns for the same session. + HostedWorkflowRunResult first = await state.RunOrResumeAsync("s1", "go"); + HostedWorkflowRunResult second = await state.RunOrResumeAsync("s1", "go"); + + // Assert: the second turn resumed the first's state (count advanced 1 -> 2), proving a fresh instance + // resumed from the checkpoint rather than starting over. + Assert.Contains("count:1", StringOutput(first)); + Assert.Contains("count:2", StringOutput(second)); + } + + [Fact] + public async Task RunOrResumeAsync_CachedFactory_BuildsOnceAndReusesAsync() + { + // Arrange: a cached factory (cacheWorkflow: true) must build the workflow once and reuse it. + int builds = 0; + var state = new HostedWorkflowState( + _ => + { + Interlocked.Increment(ref builds); + return new ValueTask(CountingWorkflow.Build()); + }, + cacheWorkflow: true); + + // Act: two turns for the same session. + HostedWorkflowRunResult first = await state.RunOrResumeAsync("s1", "go"); + HostedWorkflowRunResult second = await state.RunOrResumeAsync("s1", "go"); + + // Assert: the factory ran exactly once (cached), and the reused instance still advanced state 1 -> 2. + Assert.Equal(1, builds); + Assert.Contains("count:1", StringOutput(first)); + Assert.Contains("count:2", StringOutput(second)); + } + + [Fact] + public async Task RunOrResumeAsync_UncachedFactory_BuildsPerRunAsync() + { + // Arrange: the default (uncached) factory builds a fresh instance for every run. + int builds = 0; + var state = new HostedWorkflowState( + _ => + { + Interlocked.Increment(ref builds); + return new ValueTask(CountingWorkflow.Build()); + }); + + // Act: two turns for the same session. + _ = await state.RunOrResumeAsync("s1", "go"); + _ = await state.RunOrResumeAsync("s1", "go"); + + // Assert: the factory ran once per run. + Assert.Equal(2, builds); } [Fact] From 57c4c9d0bd97e4114db0b283bc4066837c068f43 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:44:37 +0100 Subject: [PATCH 27/28] Clarify in ADR-0032 how .NET covers AgentState factory and async-setup via DI --- .../0032-dotnet-hosting-protocol-helpers.md | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/decisions/0032-dotnet-hosting-protocol-helpers.md b/docs/decisions/0032-dotnet-hosting-protocol-helpers.md index 987447c5d94..907410e1cc3 100644 --- a/docs/decisions/0032-dotnet-hosting-protocol-helpers.md +++ b/docs/decisions/0032-dotnet-hosting-protocol-helpers.md @@ -52,7 +52,7 @@ A capability comparison of the ADR-0027 / PR #6891 helper surface against the ex | `responses_from_streaming_run` | `AgentResponseUpdateExtensions.ToStreamingResponseAsync` + `SseJsonResult` (also renders workflow events) | exists, internal, richer | | `responses_session_id` | continuity resolved inside `InMemoryResponsesService` | exists, internal, not standalone | | `create_response_id` | `IdGenerator` | exists, internal | -| `AgentState` (target + store, get-or-create) | `AgentSessionStore` (get-or-create + save + serialize + isolation) | richer; create-on-miss lives in the store, so no separate holder is needed | +| `AgentState` (target + store, get-or-create, callable/awaitable target) | `AgentSessionStore` (get-or-create + save + serialize + isolation) + DI container (target lifetime + async setup) | create-on-miss lives in the store; per-run instance and deferred/async target come from DI, so no separate holder is needed | | `SessionStore` (get/set/delete) | `AgentSessionStore` + `InMemoryAgentSessionStore` | richer; `Delete` added | | `WorkflowState` + checkpoint resume | `WorkflowCatalog`/`HostedWorkflowBuilder`; workflow events already render over Responses; `CheckpointManager` is session-keyed | partial; no per-session checkpoint cursor | | App owns routing/auth/middleware/storage | `MapOpenAIResponses`/`IResponsesService` own routing + storage | **the one real gap** | @@ -107,10 +107,24 @@ request object). the same stored state rather than sharing an instance), `SaveSessionAsync(agent, id, session)` persists post-run (including under a newly minted id), and `DeleteSessionAsync(agent, id)` removes it. An earlier draft added a `HostedAgentState` holder, but once create-on-miss lives in the store and the store does no - cross-call locking, the holder would only bind the `agent` argument — not enough to justify a public type. - Any coordination for concurrent runs against the same id is the application's concern. (Unlike Python, - whose `SessionStore` is get/set-only and whose `AgentState` therefore owns create-on-miss, .NET's store - already owns it.) + cross-call locking, the holder would only bind the `agent` argument, which is not enough to justify a + public type. Any coordination for concurrent runs against the same id is the application's concern. + (Unlike Python, whose `SessionStore` is get/set-only and whose `AgentState` therefore owns + create-on-miss, .NET's store already owns it.) + + Python's `AgentState` carries two further responsibilities beyond create-on-miss: it accepts a callable + or awaitable target so the host can (1) obtain a fresh agent instance per run and (2) defer expensive or + asynchronous agent setup while keeping server construction synchronous. In .NET these two concerns are + owned by the dependency-injection container, not by a hosting type. Per-run lifetime is expressed by the + registration lifetime (`AddScoped`/`AddTransient` yields a fresh `AIAgent` per request or scope, resolved + by the framework), and deferred or asynchronous construction is expressed by an async factory registration + (for example an `async` factory delegate, `ActivatorUtilities`, or resolving the agent inside the request + after any async warm-up), so the route handler resolves an already-built agent from the container. An + `AIAgent` is also safe to invoke concurrently (per-turn state lives in `AgentSession`, not the agent), so + the "fresh instance per run" motivation does not apply to it the way it does to a workflow. This is the + deliberate asymmetry with `HostedWorkflowState` below: a `Workflow` instance is a stateful run engine that + cannot be driven by two runners at once, so the factory/`cacheWorkflow` affordance is load-bearing there + for correctness, whereas for agents the container already provides both per-run instances and async setup. - `HostedWorkflowState`: a thin holder bundling a workflow target with a `CheckpointManager` and an internal `sessionId -> CheckpointInfo` head cursor, exposing `RunOrResumeAsync`. .NET's checkpoint store is already `sessionId`-keyed (unlike Python's workflow-name keying), but `CheckpointInfo` has From 17bc781a7df0365c1ad97661d382ed98c2285b31 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:59:56 +0100 Subject: [PATCH 28/28] Rebuild cached workflow after a faulted build and add checkpoint index dedup tests --- .../HostedWorkflowState.cs | 20 ++++++-- .../HostedWorkflowStateTests.cs | 34 +++++++++++++ .../FileSystemJsonCheckpointStoreTests.cs | 50 +++++++++++++++++++ 3 files changed, 100 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs index 69a17b71fb3..8c66046d231 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs @@ -121,10 +121,12 @@ public HostedWorkflowState( /// /// When (the default), the factory is invoked once per run, so independent sessions run /// in parallel. When , the factory is invoked once, lazily on first use, and the built - /// workflow is cached and reused for every run — a deferred, cached target. Because that reuses a single + /// workflow is cached and reused for every run, a deferred, cached target. Because that reuses a single /// instance (which cannot be run by two runners at once), a cached workflow's turns cannot run concurrently, /// exactly like the instance constructor. The cached build uses because the - /// single built instance is shared across runs and must not be tied to one request's cancellation. + /// single built instance is shared across runs and must not be tied to one request's cancellation. If a cached + /// build faults or is canceled, it is not reused: the next run starts a fresh build so a transient setup failure + /// does not poison every later run. /// /// is . /// @@ -166,12 +168,22 @@ private async ValueTask ResolveWorkflowAsync(CancellationToken cancell // Cached factory: build the workflow once. The lock guards only the one-time task assignment (and the // factory's synchronous prefix); the actual build is awaited outside the lock. CancellationToken.None is // used because the single built instance is shared across runs and must not be tied to one request. + // A previously cached build that faulted or was canceled is not reused: the next call starts a fresh + // build so a transient setup failure does not poison every later run with the same cached failure. + Task buildTask; lock (this._cacheSync) { - this._cachedWorkflowTask ??= this._workflowFactory!(CancellationToken.None).AsTask(); + // Reuse the cached build only when it exists and has not faulted or been canceled; otherwise start a + // fresh build so a transient setup failure does not poison every later run. + if (this._cachedWorkflowTask is not { IsFaulted: false, IsCanceled: false }) + { + this._cachedWorkflowTask = this._workflowFactory!(CancellationToken.None).AsTask(); + } + + buildTask = this._cachedWorkflowTask; } - return await this._cachedWorkflowTask.ConfigureAwait(false); + return await buildTask.ConfigureAwait(false); } /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs index fa3266be698..4972edb70ad 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs @@ -239,6 +239,40 @@ public async Task RunOrResumeAsync_CachedFactory_BuildsOnceAndReusesAsync() Assert.Contains("count:2", StringOutput(second)); } + [Fact] + public async Task RunOrResumeAsync_CachedFactory_RetriesAfterFaultedBuildAsync() + { + // Arrange: a cached factory whose first build faults, then succeeds. A faulted cached build must not be + // reused; the next run must retry rather than re-observe the same failure forever. The first build faults + // asynchronously so the faulted Task is actually cached, exercising the reuse guard. + int builds = 0; + var state = new HostedWorkflowState( + _ => + { + int attempt = Interlocked.Increment(ref builds); + if (attempt == 1) + { + return new ValueTask(Task.FromException(new InvalidOperationException("transient build failure"))); + } + + return new ValueTask(CountingWorkflow.Build()); + }, + cacheWorkflow: true); + + // Act & Assert: the first run surfaces the build failure. + await Assert.ThrowsAsync(() => state.RunOrResumeAsync("s1", "go").AsTask()); + + // A later run rebuilds (the faulted task was not cached) and succeeds. + HostedWorkflowRunResult second = await state.RunOrResumeAsync("s1", "go"); + Assert.Equal(2, builds); + Assert.Contains("count:1", StringOutput(second)); + + // Once a successful build is cached, further runs reuse it (no additional builds). + HostedWorkflowRunResult third = await state.RunOrResumeAsync("s1", "go"); + Assert.Equal(2, builds); + Assert.Contains("count:2", StringOutput(third)); + } + [Fact] public async Task RunOrResumeAsync_UncachedFactory_BuildsPerRunAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/FileSystemJsonCheckpointStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/FileSystemJsonCheckpointStoreTests.cs index 90758be4813..405357e6200 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/FileSystemJsonCheckpointStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/FileSystemJsonCheckpointStoreTests.cs @@ -160,6 +160,56 @@ public async Task CreateCheckpointAsync_ShouldNotEscapeRootFolderAsync() #endif } + [Fact] + public async Task RetrieveIndexAsync_ShouldNotReturnDuplicates_WhenIndexContainsDuplicateEntriesAsync() + { + // Arrange: create a checkpoint, then simulate a duplicated index entry on disk (the same CheckpointInfo + // written a second time). This captures the reviewer's question of whether duplicate index entries can + // surface as duplicate checkpoints; the load path guards each add with the membership set, so they cannot. + using TempDirectory tempDirectory = new(); + string sessionId = Guid.NewGuid().ToString("N"); + CheckpointInfo checkpoint; + string fileName; + + using (FileSystemJsonCheckpointStore store = new(tempDirectory)) + { + checkpoint = await store.CreateCheckpointAsync(sessionId, TestData); + fileName = store.GetFileNameForCheckpoint(sessionId, checkpoint); + } + + // Append a second, identical index line for the same checkpoint. + string indexPath = Path.Combine(tempDirectory.FullName, "index.jsonl"); + string duplicateEntry = JsonSerializer.Serialize(new CheckpointFileIndexEntry(checkpoint, fileName)); + File.AppendAllText(indexPath, duplicateEntry + Environment.NewLine); + + // Act: reopen so the store reloads the now-duplicated index from disk. + using FileSystemJsonCheckpointStore reopenedStore = new(tempDirectory); + CheckpointInfo[] index = (await reopenedStore.RetrieveIndexAsync(sessionId)).ToArray(); + + // Assert: the load path dedupes, so the checkpoint appears exactly once. + index.Should().ContainSingle().Which.Should().Be(checkpoint); + } + + [Fact] + public async Task RetrieveIndexAsync_ShouldReturnDistinctCheckpointsInCommitOrderAsync() + { + // Arrange: several checkpoints for one session must each appear exactly once, in commit order. + using TempDirectory tempDirectory = new(); + using FileSystemJsonCheckpointStore store = new(tempDirectory); + string sessionId = Guid.NewGuid().ToString("N"); + + CheckpointInfo first = await store.CreateCheckpointAsync(sessionId, TestData); + CheckpointInfo second = await store.CreateCheckpointAsync(sessionId, TestData); + CheckpointInfo third = await store.CreateCheckpointAsync(sessionId, TestData); + + // Act + CheckpointInfo[] index = (await store.RetrieveIndexAsync(sessionId)).ToArray(); + + // Assert: no duplicates, and commit order preserved. + index.Should().OnlyHaveUniqueItems(); + index.Should().Equal(first, second, third); + } + private const string InvalidPathCharsWin32 = "\\/:*?\"<>|"; private const string InvalidPathCharsUnix = "/"; private const string InvalidPathCharsMacOS = "/:";