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..907410e1cc3 --- /dev/null +++ b/docs/decisions/0032-dotnet-hosting-protocol-helpers.md @@ -0,0 +1,177 @@ +--- +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, 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** | + +.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. +- 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, 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 + 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. 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. 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 + +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 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. + +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..158d0215195 --- /dev/null +++ b/docs/specs/003-dotnet-hosting-protocol-helpers.md @@ -0,0 +1,246 @@ +--- +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 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( + string sessionId, object input, CancellationToken ct = default); +} +``` + +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 +`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, +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). +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. +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), without coupling the holder to a specific wire type. + +## 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 */; +AgentSessionStore sessionStore = new InMemoryAgentSessionStore(); // in-memory session store + +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 sessionStore.GetSessionAsync(agent, 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 sessionStore.SaveSessionAsync(agent, responseId, session, ct); + return Results.Empty; + } + + var result = await agent.RunAsync(run.Messages, session, run.Options, ct); + await sessionStore.SaveSessionAsync(agent, responseId, session, ct); + return Results.Json(OpenAIResponses.WriteResponse(result, responseId, sessionId)); +}); +``` + +### 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 + +app.MapPost("/responses", async (HttpContext http, CancellationToken ct) => +{ + using var doc = await JsonDocument.ParseAsync(http.Request.Body, cancellationToken: ct); + JsonElement body = doc.RootElement; + + // 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); + + // 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 d720b835b74..7bf9266d29c 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -314,7 +314,19 @@ - + + + + + + + + + + + + + @@ -640,7 +652,9 @@ - + + + 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..3e9256204cf --- /dev/null +++ b/dotnet/samples/04-hosting/af-hosting/README.md @@ -0,0 +1,53 @@ +# Agent Framework hosting samples (bring your own route) + +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. + +## 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) | 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: + +``` +local_responses/ +├── 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 ways to consume the endpoint from .NET, both against the same server: + +- A plain `Microsoft.Extensions.AI.IChatClient` (the lower-level chat-client path). +- A Microsoft Agent Framework `AIAgent` (the higher-level agent path). + +## Relationship to `../FoundryHostedAgents/` + +The sibling [`../FoundryHostedAgents/`](../FoundryHostedAgents) directory contains samples for agents that +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 | 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/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..9913f2d9b86 --- /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 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). +// +// 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 GetSessionStoreId 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..5f6fb1bd6d4 --- /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 +ASP.NET Core route you write, 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` (`ToAgentRunRequest`, `GetSessionStoreId`, `WriteResponse` / +`WriteResponseStreamAsync`), instead of the batteries-included `MapOpenAIResponses` endpoint. The agent has a +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). + +## 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.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 new file mode 100644 index 00000000000..b9a62c09dbc --- /dev/null +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs @@ -0,0 +1,113 @@ +// 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.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); + +// 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"; + +// 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) +{ + 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 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")]); + +// 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(); + +// 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. 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) => +{ + // 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? candidateSessionStoreId = OpenAIResponses.GetSessionStoreId(run); + string sessionStoreId = Authorize(http, candidateSessionStoreId) ?? OpenAIResponses.CreateResponseId(); + + 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) + { + 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 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. + return Results.Empty; + } + + AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options, cancellationToken).ConfigureAwait(false); + await sessionStore.SaveSessionAsync(agent, saveId, session, cancellationToken).ConfigureAwait(false); + return Results.Json(OpenAIResponses.WriteResponse(result, responseId, responseId)); +}); + +// 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. +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 new file mode 100644 index 00000000000..273e22b4e5c --- /dev/null +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md @@ -0,0 +1,45 @@ +# Server (Hosting Responses Agent) + +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.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). + +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`). + +```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..292834f932f --- /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 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). +// +// 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..a832247bbc9 --- /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 +ASP.NET Core route you write, with per-session checkpoint resume, and how to consume it from .NET two +different ways. + +``` +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`. + +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). + +## 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..7f45c15f192 --- /dev/null +++ b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/Program.cs @@ -0,0 +1,202 @@ +// 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. +// +// 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; +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: 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. +// 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 +// 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, 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 = 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 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(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] = sessionStoreId; + + 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. +/// +[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..3fd92e18a4f --- /dev/null +++ b/dotnet/samples/04-hosting/af-hosting/local_responses_workflow/Server/README.md @@ -0,0 +1,40 @@ +# Server (Hosting Responses Workflow) + +Server half of the [Hosting Responses Workflow](../README.md) sample. + +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. + +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`). + +`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" +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 + + + + + + + + + + + + + + + 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..d8786f602ad --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponses.cs @@ -0,0 +1,164 @@ +// 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; + 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()); + + var messages = new List(); + foreach (InputMessage inputMessage in request.Input.GetInputMessages()) + { + messages.Add(inputMessage.ToChatMessage()); + } + + return new OpenAIResponsesRunRequest(messages, options, request.PreviousResponseId, request.Conversation?.Id); + } + + /// + /// 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 conversation id to surface on the rendered response. + /// + /// An OpenAI Responses-shaped . + public static JsonElement WriteResponse(AgentResponse response, string responseId, string? conversationId = null) + { + ArgumentNullException.ThrowIfNull(response); + ArgumentException.ThrowIfNullOrEmpty(responseId); + + AgentInvocationContext context = CreateContext(responseId, conversationId); + 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 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? conversationId = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(updates); + ArgumentException.ThrowIfNullOrEmpty(responseId); + + AgentInvocationContext context = CreateContext(responseId, conversationId); + 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 id under which the session should be stored from an already-parsed + /// . + /// + /// The parsed request produced by . + /// + /// The previous_response_id when present; otherwise the conversation id when present; + /// otherwise when the request carries neither. + /// + /// is . + /// + /// 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 + /// 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? GetSessionStoreId(OpenAIResponsesRunRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + return request.PreviousResponseId ?? request.ConversationId; + } + + /// + /// 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? 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. + 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..a77e33cc3b8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesRunRequest.cs @@ -0,0 +1,50 @@ +// 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, string? previousResponseId = null, string? conversationId = null) + { + this.Messages = messages; + this.Options = options; + this.PreviousResponseId = previousResponseId; + this.ConversationId = conversationId; + } + + /// + /// 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; } + + /// + /// 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/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs index 7c0539fe511..e3d1c20699b 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,15 +65,44 @@ 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. - /// 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 conversationId, + string sessionStoreId, + CancellationToken cancellationToken = default); + + /// + /// Deletes a stored agent session, if present. + /// + /// The agent that owns this session. + /// The id under which the session is stored. + /// The to monitor for cancellation requests. + /// A task that represents the asynchronous delete operation. + /// + /// 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 abstract ValueTask DeleteSessionAsync( + AIAgent agent, + string sessionStoreId, CancellationToken cancellationToken = default); /// Asks the for an object of the specified type . diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs index e80d3b907a3..f340cec8af5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs @@ -53,12 +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 sessionStoreId, CancellationToken cancellationToken = default) + => this.InnerStore.DeleteSessionAsync(agent, sessionStoreId, cancellationToken); /// /// 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..8c66046d231 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowState.cs @@ -0,0 +1,385 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +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; +using Microsoft.Agents.AI.Workflows.InProc; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.Diagnostics; + +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 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 +/// 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 IWorkflowExecutionEnvironment _executionEnvironment; + 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); + + /// + /// Initializes a new instance of the class over a single shared workflow + /// instance. + /// + /// The workflow target. + /// + /// 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 . + /// + /// 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, + IWorkflowExecutionEnvironment? executionEnvironment = null, + ILoggerFactory? loggerFactory = null) + { + _ = Throw.IfNull(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)); + } + + /// + /// 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. 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 . + /// + /// 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. + // 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) + { + // 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 buildTask.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 + /// the new turn's . The new head checkpoint is recorded for the session afterwards. + /// + /// + /// 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. + /// The application-selected session id. + /// 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 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); + + 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 + // manager still resumes after the cursor is lost (for example a process restart or a new holder over + // the same store). + 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 this._executionEnvironment.RunAsync(workflow, input, sessionId, cancellationToken).ConfigureAwait(false); + await using (freshRun.ConfigureAwait(false)) + { + return this.Record(sessionId, freshRun.OutgoingEvents.ToList(), freshRun.LastCheckpoint); + } + } + + // 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 workflow.DescribeProtocolAsync(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); + if (descriptor.IsChatProtocol() && input is not TurnToken) + { + await resumed.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); + } + + List events = []; + // 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); + } + + if (events.Count == 0) + { + 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 when the stream ends, + /// including when the consumer abandons enumeration early. + /// + /// + /// 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. + /// 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); + + Workflow workflow = await this.ResolveWorkflowAsync(cancellationToken).ConfigureAwait(false); + + if (!this._cursor.TryGetValue(sessionId, out CheckpointInfo? head)) + { + head = await this._checkpointManager.GetLatestCheckpointAsync(sessionId, 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(workflow, input, sessionId, cancellationToken).ConfigureAwait(false) + : await this._executionEnvironment.ResumeStreamingAsync(workflow, head, 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; + 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)) + { + eventCount++; + yield return evt; + } + + if (eventCount == 0 && head is not null) + { + this.WarnOnNoProgress(sessionId); + } + } + 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); + } + } + } + + 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; + } + } + + private void WarnOnNoProgress(string sessionId) + // 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); + + /// + /// 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 . + /// + /// 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/IsolationKeyScopedAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs index f379d625fd1..87c33a567a9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs @@ -63,47 +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 sessionStoreId, CancellationToken cancellationToken = default) + { + 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 832c411977f..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 @@ -63,5 +63,12 @@ public override async ValueTask GetSessionAsync(AIAgent agent, str }; } - private static string GetKey(string conversationId, string agentId) => $"{agentId}:{conversationId}"; + /// + public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) + { + this._threads.TryRemove(GetKey(sessionStoreId, agent.Id), out _); + return default; + } + + 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 163c1ea867e..a156285a856 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs @@ -12,14 +12,20 @@ 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 new ValueTask(); + 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 sessionStoreId, CancellationToken cancellationToken = default) + { + return default; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/CheckpointManager.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/CheckpointManager.cs index 2b5bb5b0347..efaf4f8abd7 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,29 @@ 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) + { + // 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; + foreach (CheckpointInfo info in index) + { + latest = info; + } + + return latest; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs index 0c3d57976d2..cb0891d6179 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs @@ -34,7 +34,19 @@ 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 + // 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 +92,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 +145,8 @@ private CheckpointInfo GetUnusedCheckpointInfo(string sessionId) key = new(sessionId); } while (!this.CheckpointIndex.Add(key)); + this.OrderedCheckpointIndex.Add(key); + return key; } @@ -164,6 +182,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 +221,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/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); /// 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.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..1aa58c177dc --- /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(); + 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 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); + + // 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(); + AgentSessionStore sessionStore = new InMemoryAgentSessionStore(); + + // Act: first turn establishes context, second turn continues from the first response id. + 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 sessionStore.GetSessionAsync(agent, secondSessionStoreId); + AgentResponse secondResult = await agent.RunAsync(secondRun.Messages, session, secondRun.Options); + + // Assert: continuation succeeded and the model produced a textual answer. + Assert.Equal(secondSessionStoreId, firstResponseId); + Assert.False(string.IsNullOrWhiteSpace(secondResult.Text)); + } + + 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 sessionStore.GetSessionAsync(agent, sessionStoreId); + string responseId = OpenAIResponses.CreateResponseId(); + _ = await agent.RunAsync(run.Messages, session, run.Options); + await sessionStore.SaveSessionAsync(agent, 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..8baa60598c2 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs @@ -0,0 +1,298 @@ +// 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 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 +/// project. +/// +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; + + [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 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)); + Assert.Contains("first turn", secondCallText, StringComparison.Ordinal); + 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() + { + // 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"); + AgentSessionStore sessionStore = new InMemoryAgentSessionStore(); + + this._app.MapPost("/responses", async (JsonElement body, HttpContext http, CancellationToken ct) => + { + OpenAIResponsesRunRequest run; + try + { + run = OpenAIResponses.ToAgentRunRequest(body); + } + catch (ArgumentException) + { + return Results.BadRequest(); + } + + string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); + 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"; + 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 sessionStore.SaveSessionAsync(agent, saveId, session, ct); + return Results.Empty; + } + + AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options, ct); + await sessionStore.SaveSessionAsync(agent, saveId, 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 sessionStoreId = run.ConversationId ?? OpenAIResponses.CreateResponseId(); + + 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(), sessionStoreId)); + }); + + 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"); + + 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.OpenAI.UnitTests/OpenAIResponsesTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs new file mode 100644 index 00000000000..2ed5b735acb --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +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 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.GetSessionStoreId(request)); + } + + [Fact] + 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("resp_abc", OpenAIResponses.GetSessionStoreId(request)); + } + + [Fact] + public void GetSessionStoreId_NoContinuationKey_ReturnsNull() + { + // Arrange + using var doc = JsonDocument.Parse("""{ "input": "hi" }"""); + var request = OpenAIResponses.ToAgentRunRequest(doc.RootElement); + + // Act & Assert + Assert.Null(OpenAIResponses.GetSessionStoreId(request)); + } + + [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 ToAgentRunRequest_MalformedBody_ThrowsArgumentException() + { + // Arrange (an array is not a valid Responses body) + using var doc = JsonDocument.Parse("""[ 1, 2, 3 ]"""); + + // Act & Assert + Assert.Throws("body", () => OpenAIResponses.ToAgentRunRequest(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, conversationId: "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/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/BriefWorkflow.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/BriefWorkflow.cs new file mode 100644 index 00000000000..adcbf8f0e87 --- /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>. +/// +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/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/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/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/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/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 new file mode 100644 index 00000000000..4972edb70ad --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedWorkflowStateTests.cs @@ -0,0 +1,496 @@ +// Copyright (c) Microsoft. All rights reserved. + +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; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +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((Workflow)null!)); + + [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()); + } + + [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); + } + + [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); + } + + [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). + Assert.Empty(second.Events); + 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 _)); + } + + [Fact] + public void Constructor_NullFactory_Throws() => + // Act & Assert + Assert.Throws("workflowFactory", () => new HostedWorkflowState((Func>)null!)); + + [Fact] + public async Task RunOrResumeAsync_Factory_ConcurrentDifferentSessions_RunInParallelAsync() + { + // 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( + _ => new ValueTask(GatedCountingWorkflow.Build(entered, release.Task)), + CheckpointManager.CreateInMemory()); + + // Act: start two turns for DIFFERENT sessions. + Task first = state.RunOrResumeAsync("s1", "go").AsTask(); + Task second = state.RunOrResumeAsync("s2", "go").AsTask(); + + // 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 and let them complete. + release.SetResult(); + HostedWorkflowRunResult[] results = await Task.WhenAll(first, second).WaitAsync(TimeSpan.FromSeconds(30)); + + // 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_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() + { + // 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] + 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); + } + + [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); + } + + [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 via the + // generic TInput. + 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)); + } + + [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)); + } + + [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) => + string.Concat( + result.Events + .OfType() + .Select(e => e.Data) + .OfType>() + .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")]); + + 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..8af3fc43ece --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs @@ -0,0 +1,135 @@ +// 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; + +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_StoreOptsOut_ThrowsNotSupportedAsync() + { + // Arrange: a store that chooses not to support deletion throws NotSupportedException itself. + AgentSessionStore store = new ConcreteAgentSessionStore(); + + // Act & Assert + 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 + { + public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) + => 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(); + } + + // 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() + { + } + } +} 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 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); + } +} 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) { } + } +} 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]); + } +} 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 = "/:";