.NET: [BREAKING] Hosting OpenAI Responses protocol helpers and optional execution state (#7000)
* .NET: Add OpenAI Responses protocol helpers and optional execution state (ADR-0032)
* Fix netstandard2.0/net472 build; harden helpers and workflow checkpoint key per review
* .NET: Migrate hosting Responses samples to Azure.AI.Projects and fix workflow resume
Migrate HostingResponsesAgent and HostingResponsesWorkflow samples from
Azure.AI.OpenAI to Azure.AI.Projects (AIProjectClient.AsAIAgent), using the
FOUNDRY_PROJECT_ENDPOINT/FOUNDRY_MODEL convention.
Fix HostedWorkflowState.RunOrResumeAsync: on subsequent turns, restore the
session's latest checkpoint and run the workflow forward with the new turn's
input (mirroring the Python hosting host's restore-then-run semantics) instead
of resuming a halted run with no input, which waited on input indefinitely.
Add round-trip resume tests and update ADR-0032/spec-003 wording.
* .NET: Fix HostedWorkflowState resume hang on unserviced external requests
On resume, HostedWorkflowState.RunOrResumeAsync drained the workflow with the
blocking WatchStreamAsync overload, so a workflow that halts at an unserviced
RequestInfoEvent (human-in-the-loop / approval) blocked forever — asymmetric
with the first-turn RunAsync path, which returns at the same halt. Break the
drain when a superstep completes with HasPendingRequests, restoring symmetry
with turn 1. Add a HITL approval-gate workflow and a resume-does-not-block test.
* .NET: Warn when a HostedWorkflowState resume makes no progress
Add an optional ILoggerFactory to HostedWorkflowState and log a warning when a
resumed turn produces no events, mirroring the Python host's zero-event restore
warning (a stale checkpoint or an input that does not match the workflow's
expected type leaves session state unprogressed). Add a non-chat string workflow
helper, a capturing logger, and a red/green test.
* .NET: Resume HostedWorkflowState from durable checkpoint on cursor miss
Add CheckpointManager.GetLatestCheckpointAsync(sessionId) and have
HostedWorkflowState fall back to it when its in-memory head cursor misses, so a
durable CheckpointManager resumes a session across a process restart or a new
holder instead of restarting from the workflow's start executor. Mirrors the
Python host's per-turn get_latest read-through. Add a counting workflow that
proves resume-vs-fresh via accumulated state, plus a red/green test, and update
ADR-0032/spec-003 and the XML remarks.
* .NET: Serialize HostedWorkflowState turns through a workflow lock
A single workflow instance backs the holder and workflow instances do not
support concurrent runs (the runner throws "already owned by another runner"),
so concurrent turns could fault or race the head cursor. Serialize all turns
through one SemaphoreSlim (mirroring the Python host's workflow lock) and make
HostedWorkflowState IDisposable to own it. Add a gated workflow and a
deterministic concurrency red/green test.
* .NET: Cover non-chat resume and multi-turn checkpoint advance
Add tests for HostedWorkflowState resuming a non-chat-protocol workflow (no
TurnToken) and for a third turn continuing to advance the head checkpoint,
closing the coverage gaps the parity review flagged.
* .NET: Add streaming workflow resume path and stream the workflow sample
Add HostedWorkflowState.RunOrResumeStreamingAsync, which yields the turn's
WorkflowEvents as they occur (fresh run or checkpoint resume) under the same
serialization lock and records the head checkpoint after the stream drains,
keeping the blocking and streaming workflow paths in lockstep with the Python
host. Honor stream:true in the HostingResponsesWorkflow sample by projecting
AgentResponseUpdateEvent updates over the Responses SSE wire. Add a streaming
resume test and update the README/spec.
* .NET: Cover Responses input adaptation to a typed workflow start executor
Demonstrate that HostedWorkflowState's generic RunOrResumeAsync<TInput> is the
input-adaptation seam (parity with Python's ResponsesChannel run hook): the app
adapts the Responses input into the workflow start executor's own type at the
call site. Add a typed-brief workflow and a test, and note the seam in spec-003.
* .NET: Drain workflow resume non-blocking to prevent hang and truncation
The resume drain used a SuperStepCompletedEvent{HasPendingRequests} proxy over
the blocking public WatchStreamAsync. That proxy (a) truncated a resumed turn
when a superstep both emitted a request and queued downstream work, and (b)
could fail to fire at all — re-introducing the indefinite hang — when a resume
input drove no superstep (e.g. a rejected non-chat input).
Make StreamingRun.WatchStreamAsync(bool blockOnPendingRequest, CancellationToken)
public and drain both the blocking and streaming resume paths with
blockOnPendingRequest:false, exactly matching the first-turn RunAsync semantics
(Run.RunToNextHaltAsync). Add guard tests: resume with a rejected input does not
hang, and a resume superstep with a request plus downstream work is not
truncated (verified red against the old proxy).
* .NET: Return file-store checkpoint index in commit order
CheckpointManager.GetLatestCheckpointAsync takes the last entry of a store's
index as the head checkpoint. FileSystemJsonCheckpointStore backed its index
with a HashSet, whose enumeration order is not contractual: after a rollback
frees and reuses a slot, enumeration can diverge from commit order, so the
durable read-through could resume a stale checkpoint. Mirror the HashSet with an
insertion-ordered list and enumerate it from RetrieveIndexAsync so 'latest' is
reliable. Add a CheckpointManager.GetLatestCheckpointAsync contract test over the
file store.
Note: the HashSet disorder is only reachable via the internal rollback path, so
the test locks the ordering contract rather than reproducing the rare disorder.
* .NET: Advance cursor when a streaming resume is abandoned
RunOrResumeStreamingAsync recorded the head checkpoint only after the stream was
fully enumerated. If an SSE consumer disconnected mid-turn after supersteps had
committed, the in-memory cursor kept the previous turn's head; because the next
turn is then a cursor hit, durable read-through could not self-heal, so it
resumed pre-disconnect state. Record the run's last committed checkpoint in a
finally so an abandoned stream still advances the cursor. Add a red/green test.
* .NET: Stream only the final agent's updates in the workflow sample
ExtractUpdates streamed every agent's updates, so the sequential Writer->Reviewer
sample streamed the intermediate draft and the final answer over SSE, differing
from the non-streaming response (final message only). Filter the streamed updates
to the final agent so streaming and non-streaming produce the same response.
Live-verified against Foundry: one output item streamed instead of two.
* .NET: Isolate the holder lock in the concurrency test
The concurrency test asserted the second same-session turn did not enter the
workflow, which also passes via the engine's concurrent-run ownership guard
(which faults) rather than the holder lock (which waits). Assert instead that the
second turn is not completed while the first holds the lock: a fault would
complete the task, so a pending task isolates the holder lock from the engine
guard. Verified red with the lock removed.
* Fix IDE1006 naming in tests; address review feedback and add hosting/live tests
* Document commit-order contract for ICheckpointStore.RetrieveIndexAsync
* Restructure hosting samples under af-hosting with client/server split matching Python parity
* Clarify hosting sample README wording and drop Python comparisons
* Make AgentSessionStore.DeleteSessionAsync abstract and rename session id parameter to sessionStoreId
* Rename OpenAIResponses id helpers and parse the request once for id extraction
* Reclaim per-session locks in HostedAgentState and demonstrate session locking in the agent sample
* Internalize per-session locking in HostedAgentState (automatic, on by default) and remove mirroring-Python wording from code and spec
* Remove HostedAgentState; app-owned routes use AgentSessionStore directly
HostedAgentState only bundled an AIAgent with an AgentSessionStore and, after
the per-session lock was removed, its GetOrCreateSessionAsync/SaveSessionAsync/
DeleteSessionAsync were pass-throughs that just bound the agent argument.
Create-on-miss already lives in the store (unlike Python, whose get/set-only
SessionStore justifies its AgentState holder), so the type earned its place
only via the lock.
Each AgentSessionStore.GetSessionAsync now returns an independent session
instance per call, so concurrent gets fork the same stored state (e.g.
branching from previous_response_id or managing several conversation ids)
without sharing an instance. The store does no cross-call locking; serializing
concurrent runs against the same id is the application's concern.
- Delete HostedAgentState and its unit tests.
- Rewire the local_responses sample and the OpenAI hosting unit/integration
tests to call AgentSessionStore (GetSessionAsync/SaveSessionAsync) directly.
- Update ADR-0032, spec-003, and the af-hosting sample READMEs.
* Isolate hosted session snapshots and distinguish conversation vs response continuation
Mirrors the Python hosted-session isolation work: a hosted session read must be
an independent copy, and the app-owned route must persist under the right
continuation key depending on how the caller continued the thread.
- AgentSessionStore.GetSessionAsync: document the isolation invariant (each
call returns an independent AgentSession so concurrent branches from one
previous_response_id do not observe each other's mutations or alter stored
state); fix the stale "or null if not found" wording (in-box stores return a
fresh created session on miss). The in-box stores already satisfy this via a
serialize/deserialize snapshot round-trip.
- local_responses sample + hosting unit-test route: choose the save key by
channel. A stable conversation id is a mutable head (write back under the
same id; app owns single-writer coordination). A previous_response_id
continuation or first turn is an immutable snapshot (save under the new
response id so branches from the same prior response stay independent).
- Add regression tests: independent get returns a distinct instance
(InMemoryAgentSessionStore); previous_response_id supports independent
branches ([1,2,2,3,3]); conversation id advances the mutable head ([1,2]).
- Update the sample README and ADR-0032 wording.
* Add workflow-factory support to HostedWorkflowState for concurrent sessions
HostedWorkflowState backed every session with one shared Workflow instance and
serialized all turns through a lock, so independent sessions could not run
concurrently. Add a workflow-factory constructor and remove the run lock.
- New constructor HostedWorkflowState(Func<CancellationToken, ValueTask<Workflow>>
workflowFactory, ..., bool cacheWorkflow = false):
- cacheWorkflow: false (default) builds a fresh instance per run, so independent
sessions run in parallel. A resume rehydrates a fresh instance from the
session's checkpoint in the shared store.
- cacheWorkflow: true builds the workflow once, lazily on first use, and reuses
it (a deferred, cached target that, like a shared instance, cannot run
concurrent turns).
- Remove the internal SemaphoreSlim run lock and IDisposable; the instance
constructor is unchanged in behaviour (one shared instance still cannot run
concurrent turns). Turns are no longer serialized by the holder; a single
writer per session is the application's responsibility.
- Switch the local_responses_workflow sample to the factory constructor with an
explicit cacheWorkflow: false, and document the option.
- Add tests: parallel independent sessions (factory), fresh-instance resume,
cached factory builds once and reuses, uncached factory builds per run.
- Update ADR-0032, spec-003, and the sample README.
* Clarify in ADR-0032 how .NET covers AgentState factory and async-setup via DI
* Rebuild cached workflow after a faulted build and add checkpoint index dedup tests
This commit is contained in:
@@ -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<AgentRunResponseUpdate> 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<CancellationToken, ValueTask<Workflow>>`). 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`.
|
||||
@@ -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<string> WriteResponseStreamAsync(
|
||||
IAsyncEnumerable<AgentResponseUpdate> 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<ChatMessage> 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<CancellationToken, ValueTask<Workflow>> 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<HostedWorkflowRunResult> 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<CancellationToken, ValueTask<Workflow>>`) 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));
|
||||
});
|
||||
```
|
||||
@@ -314,7 +314,19 @@
|
||||
<Project Path="samples/03-workflows/Evaluation/Evaluation_WorkflowEval/Evaluation_WorkflowEval.csproj" />
|
||||
<Project Path="samples/03-workflows/Evaluation/Evaluation_WorkflowExpectedOutputs/Evaluation_WorkflowExpectedOutputs.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/">
|
||||
<Folder Name="/Samples/04-hosting/" />
|
||||
<Folder Name="/Samples/04-hosting/af-hosting/">
|
||||
<File Path="samples/04-hosting/af-hosting/README.md" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/af-hosting/local_responses/">
|
||||
<File Path="samples/04-hosting/af-hosting/local_responses/README.md" />
|
||||
<Project Path="samples/04-hosting/af-hosting/local_responses/Server/Server.csproj" />
|
||||
<Project Path="samples/04-hosting/af-hosting/local_responses/Client/Client.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/af-hosting/local_responses_workflow/">
|
||||
<File Path="samples/04-hosting/af-hosting/local_responses_workflow/README.md" />
|
||||
<Project Path="samples/04-hosting/af-hosting/local_responses_workflow/Server/Server.csproj" />
|
||||
<Project Path="samples/04-hosting/af-hosting/local_responses_workflow/Client/Client.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/" />
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/invocations/" />
|
||||
@@ -640,7 +652,9 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Tests/" />
|
||||
<Folder Name="/Tests/">
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Tests/IntegrationTests/">
|
||||
<Project Path="tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj" />
|
||||
<Project Path="tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletion.IntegrationTests.csproj" />
|
||||
|
||||
@@ -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 |
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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://<your-resource>.services.ai.azure.com/api/projects/<your-project>"
|
||||
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`.
|
||||
@@ -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;
|
||||
@@ -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://<your-resource>.services.ai.azure.com/api/projects/<your-project>"
|
||||
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 }'
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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://<your-resource>.services.ai.azure.com/api/projects/<your-project>"
|
||||
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.
|
||||
@@ -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<Workflow>(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<string, string>(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<ChatMessage> 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."));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="TurnToken"/> that drives the downstream agent. This keeps the workflow non-chat-protocol (its
|
||||
/// output is a plain string) while still driving the agent.
|
||||
/// </summary>
|
||||
[SendsMessage(typeof(ChatMessage))]
|
||||
[SendsMessage(typeof(TurnToken))]
|
||||
internal sealed class BriefExecutor() : Executor<string>("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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats the writer agent's output as the workflow's final response: one terminal-friendly slogan line.
|
||||
/// </summary>
|
||||
internal sealed class SloganFormatterExecutor() : Executor<List<ChatMessage>, string>("terminal_formatter")
|
||||
{
|
||||
public override ValueTask<string> HandleAsync(List<ChatMessage> 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}\"");
|
||||
}
|
||||
}
|
||||
@@ -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://<your-resource>.services.ai.azure.com/api/projects/<your-project>"
|
||||
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_..." }'
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// These helpers are the app-owned-routing counterpart to <c>MapOpenAIResponses</c>.
|
||||
/// <c>MapOpenAIResponses</c> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Trust boundary.</strong> <see cref="GetSessionStoreId(OpenAIResponsesRunRequest)"/> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class OpenAIResponses
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts an OpenAI Responses request body into Agent Framework run values (messages and options).
|
||||
/// </summary>
|
||||
/// <param name="body">The OpenAI Responses-shaped request body.</param>
|
||||
/// <param name="mapOptions">
|
||||
/// Optional options controlling how request settings are mapped onto the run. By default no request
|
||||
/// setting is mapped onto the run.
|
||||
/// </param>
|
||||
/// <returns>The parsed messages and mapped run options.</returns>
|
||||
/// <exception cref="ArgumentException">The body could not be parsed as an OpenAI Responses request.</exception>
|
||||
/// <exception cref="NotSupportedException">A request setting is not supported by the configured mapping.</exception>
|
||||
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<ChatMessage>();
|
||||
foreach (InputMessage inputMessage in request.Input.GetInputMessages())
|
||||
{
|
||||
messages.Add(inputMessage.ToChatMessage());
|
||||
}
|
||||
|
||||
return new OpenAIResponsesRunRequest(messages, options, request.PreviousResponseId, request.Conversation?.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a final <see cref="AgentResponse"/> into an OpenAI Responses-shaped payload.
|
||||
/// </summary>
|
||||
/// <param name="response">The agent response to render.</param>
|
||||
/// <param name="responseId">The id to assign to the rendered response (see <see cref="CreateResponseId"/>).</param>
|
||||
/// <param name="conversationId">
|
||||
/// The optional conversation id to surface on the rendered response.
|
||||
/// </param>
|
||||
/// <returns>An OpenAI Responses-shaped <see cref="JsonElement"/>.</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a stream of <see cref="AgentResponseUpdate"/> into OpenAI Responses Server-Sent-Event frames.
|
||||
/// </summary>
|
||||
/// <param name="updates">The agent streaming updates.</param>
|
||||
/// <param name="responseId">The id to assign to the rendered response (see <see cref="CreateResponseId"/>).</param>
|
||||
/// <param name="conversationId">The optional conversation id to surface on the rendered response.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>An async sequence of SSE frame strings, each terminated by a blank line.</returns>
|
||||
public static async IAsyncEnumerable<string> WriteResponseStreamAsync(
|
||||
IAsyncEnumerable<AgentResponseUpdate> 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";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the id under which the session should be stored from an already-parsed
|
||||
/// <see cref="OpenAIResponsesRunRequest"/>.
|
||||
/// </summary>
|
||||
/// <param name="request">The parsed request produced by <see cref="ToAgentRunRequest(JsonElement, OpenAIResponsesMapOptions?)"/>.</param>
|
||||
/// <returns>
|
||||
/// The <c>previous_response_id</c> when present; otherwise the <c>conversation</c> id when present;
|
||||
/// otherwise <see langword="null"/> when the request carries neither.
|
||||
/// </returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="request"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// This reads the ids off the already-parsed request rather than re-parsing the body, so an application
|
||||
/// calls <see cref="ToAgentRunRequest(JsonElement, OpenAIResponsesMapOptions?)"/> 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 <strong>untrusted candidate key</strong> until the application has
|
||||
/// authorized it for the caller. A <see langword="null"/> result means only that the request carried no
|
||||
/// continuation id (unparseable bodies are already rejected by <see cref="ToAgentRunRequest(JsonElement, OpenAIResponsesMapOptions?)"/>).
|
||||
/// <para>
|
||||
/// The Responses protocol treats <c>previous_response_id</c> and <c>conversation</c> as mutually exclusive; if a
|
||||
/// request carries both, this helper prefers <c>previous_response_id</c> (the response-chain pointer). Note that
|
||||
/// <c>previous_response_id</c> changes each turn and is therefore not a stable partition key; use
|
||||
/// <see cref="OpenAIResponsesRunRequest.ConversationId"/> when a stable key is required (for example a workflow
|
||||
/// checkpoint cursor key).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static string? GetSessionStoreId(OpenAIResponsesRunRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
return request.PreviousResponseId ?? request.ConversationId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new OpenAI Responses-shaped response id (a <c>resp_*</c> value).
|
||||
/// </summary>
|
||||
/// <returns>A new response id.</returns>
|
||||
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) };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI;
|
||||
|
||||
/// <summary>
|
||||
/// The result of converting an OpenAI Responses request body into Agent Framework run values via
|
||||
/// <see cref="OpenAIResponses.ToAgentRunRequest(System.Text.Json.JsonElement, OpenAIResponsesMapOptions?)"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This type carries the values an application passes to <see cref="AIAgent.RunAsync(IEnumerable{ChatMessage}, AgentSession?, AgentRunOptions?, System.Threading.CancellationToken)"/>
|
||||
/// (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.
|
||||
/// </remarks>
|
||||
public sealed class OpenAIResponsesRunRequest
|
||||
{
|
||||
internal OpenAIResponsesRunRequest(IList<ChatMessage> messages, AgentRunOptions? options, string? previousResponseId = null, string? conversationId = null)
|
||||
{
|
||||
this.Messages = messages;
|
||||
this.Options = options;
|
||||
this.PreviousResponseId = previousResponseId;
|
||||
this.ConversationId = conversationId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the chat messages parsed from the request body, ready to pass to an <see cref="AIAgent"/> run.
|
||||
/// </summary>
|
||||
public IList<ChatMessage> Messages { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the run options mapped from the request, or <see langword="null"/> when no request setting is
|
||||
/// mapped onto the run. The mapping is controlled by <see cref="OpenAIResponsesMapOptions.RunOptionsFactory"/>;
|
||||
/// by default no request setting is mapped.
|
||||
/// </summary>
|
||||
public AgentRunOptions? Options { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the request's <c>previous_response_id</c> continuation pointer, or <see langword="null"/> when absent.
|
||||
/// This changes each turn (it follows the response chain), so it is not a stable partition key.
|
||||
/// </summary>
|
||||
public string? PreviousResponseId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the request's <c>conversation</c> id, or <see langword="null"/> when absent. Unlike
|
||||
/// <see cref="PreviousResponseId"/>, this is stable across turns, so it is a valid stable partition key.
|
||||
/// </summary>
|
||||
public string? ConversationId { get; }
|
||||
}
|
||||
@@ -17,17 +17,17 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
/// or different service instances in hosted scenarios.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Trust model.</strong> The <c>conversationId</c> passed to
|
||||
/// <see cref="GetSessionAsync"/> and <see cref="SaveSessionAsync"/> typically originates
|
||||
/// from the wire (for example, an AG-UI <c>RunAgentInput.ThreadId</c> or an A2A
|
||||
/// <c>contextId</c>). It is a chain-resume identifier, <em>not</em> an authorization
|
||||
/// token, and the <c>(agent, conversationId)</c> tuple carries no principal/owner
|
||||
/// <strong>Trust model.</strong> The <c>sessionStoreId</c> passed to
|
||||
/// <see cref="GetSessionAsync"/> and <see cref="SaveSessionAsync"/> is the id under which the session is
|
||||
/// stored. It typically originates from the wire (for example, an AG-UI <c>RunAgentInput.ThreadId</c> or an
|
||||
/// A2A <c>contextId</c>). It is a chain-resume identifier, <em>not</em> an authorization
|
||||
/// token, and the <c>(agent, sessionStoreId)</c> 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 <c>conversationId</c> can resume
|
||||
/// who knows or guesses another caller's <c>sessionStoreId</c> can resume
|
||||
/// that other caller's persisted thread. The framework provides
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/> as a decorator that rewrites
|
||||
/// <c>conversationId</c> to include an isolation key resolved from a
|
||||
/// <c>sessionStoreId</c> to include an isolation key resolved from a
|
||||
/// <see cref="SessionIsolationKeyProvider"/> (for example, the ASP.NET Core
|
||||
/// <c>ClaimsIdentitySessionIsolationKeyProvider</c> wired up via
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c>). When no provider is registered, the
|
||||
@@ -36,12 +36,12 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Implementer guidance.</strong> Implementations should treat
|
||||
/// <c>conversationId</c> as opaque: do not parse it, do not impose length
|
||||
/// <c>sessionStoreId</c> 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
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/> may rewrite it before forwarding).
|
||||
/// Be aware that any logging, telemetry, or audit sink that surfaces
|
||||
/// <c>conversationId</c> will also surface the isolation prefix when a
|
||||
/// <c>sessionStoreId</c> will also surface the isolation prefix when a
|
||||
/// scoping decorator is in the chain.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
@@ -51,13 +51,13 @@ public abstract class AgentSessionStore
|
||||
/// Saves a serialized agent session to persistent storage.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent that owns this session.</param>
|
||||
/// <param name="conversationId">The unique identifier for the conversation/session.</param>
|
||||
/// <param name="sessionStoreId">The id under which the session is stored.</param>
|
||||
/// <param name="session">The session to save.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A task that represents the asynchronous save operation.</returns>
|
||||
public abstract ValueTask SaveSessionAsync(
|
||||
AIAgent agent,
|
||||
string conversationId,
|
||||
string sessionStoreId,
|
||||
AgentSession session,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -65,15 +65,44 @@ public abstract class AgentSessionStore
|
||||
/// Retrieves a serialized agent session from persistent storage.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent that owns this session.</param>
|
||||
/// <param name="conversationId">The unique identifier for the conversation/session to retrieve.</param>
|
||||
/// <param name="sessionStoreId">The id under which the session is stored.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous retrieval operation.
|
||||
/// The task result contains the serialized session state, or <see langword="null"/> if not found.
|
||||
/// A task that represents the asynchronous retrieval operation. The task result contains the
|
||||
/// restored <see cref="AgentSession"/>, or a newly created session when nothing is stored for the id.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// <strong>Isolation.</strong> Each call must return an <em>independent</em> <see cref="AgentSession"/>
|
||||
/// instance. Callers may mutate the returned session, and may run several concurrent branches from the
|
||||
/// same <paramref name="sessionStoreId"/> (for example forking from an OpenAI Responses
|
||||
/// <c>previous_response_id</c>), 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 <see cref="AgentSession"/> must return an
|
||||
/// independent copy (for example by round-tripping through
|
||||
/// <see cref="AIAgent.SerializeSessionAsync(AgentSession, System.Text.Json.JsonSerializerOptions?, CancellationToken)"/>
|
||||
/// and <see cref="AIAgent.DeserializeSessionAsync(System.Text.Json.JsonElement, System.Text.Json.JsonSerializerOptions?, CancellationToken)"/>)
|
||||
/// rather than handing back the shared instance.
|
||||
/// </remarks>
|
||||
public abstract ValueTask<AgentSession> GetSessionAsync(
|
||||
AIAgent agent,
|
||||
string conversationId,
|
||||
string sessionStoreId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a stored agent session, if present.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent that owns this session.</param>
|
||||
/// <param name="sessionStoreId">The id under which the session is stored.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A task that represents the asynchronous delete operation.</returns>
|
||||
/// <remarks>
|
||||
/// Implementations that support removal delete the session and treat a missing session as a no-op.
|
||||
/// Implementations that genuinely cannot support deletion should throw <see cref="NotSupportedException"/>.
|
||||
/// </remarks>
|
||||
/// <exception cref="NotSupportedException">The store does not support deletion.</exception>
|
||||
public abstract ValueTask DeleteSessionAsync(
|
||||
AIAgent agent,
|
||||
string sessionStoreId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Asks the <see cref="AgentSessionStore"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
|
||||
|
||||
@@ -53,12 +53,16 @@ public abstract class DelegatingAgentSessionStore : AgentSessionStore
|
||||
protected AgentSessionStore InnerStore { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
=> this.InnerStore.GetSessionAsync(agent, conversationId, cancellationToken);
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
|
||||
=> this.InnerStore.GetSessionAsync(agent, sessionStoreId, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
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);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
|
||||
=> this.InnerStore.DeleteSessionAsync(agent, sessionStoreId, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// The result of a <see cref="HostedWorkflowState"/> run or resume.
|
||||
/// </summary>
|
||||
public sealed class HostedWorkflowRunResult
|
||||
{
|
||||
internal HostedWorkflowRunResult(string sessionId, IReadOnlyList<Workflows.WorkflowEvent> events, Workflows.CheckpointInfo? checkpoint)
|
||||
{
|
||||
this.SessionId = sessionId;
|
||||
this.Events = events;
|
||||
this.Checkpoint = checkpoint;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the application-selected session id this run was executed under.
|
||||
/// </summary>
|
||||
public string SessionId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the workflow events emitted during this run.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Workflows.WorkflowEvent> Events { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the head checkpoint recorded for the session after this run, or <see langword="null"/> when
|
||||
/// checkpointing produced no checkpoint.
|
||||
/// </summary>
|
||||
public Workflows.CheckpointInfo? Checkpoint { get; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Workflow"/> target with a
|
||||
/// <see cref="CheckpointManager"/> and an application-scoped <c>sessionId -> CheckpointInfo</c> head cursor.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The .NET workflow checkpoint store is already keyed by session id, but <see cref="CheckpointInfo"/> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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 <see cref="CheckpointManager.GetLatestCheckpointAsync"/>. A durable
|
||||
/// <see cref="CheckpointManager"/> therefore resumes correctly across restarts; the default in-memory manager does
|
||||
/// not persist, so with it a restart starts the session fresh.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Trust boundary.</strong> <c>sessionId</c> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class HostedWorkflowState
|
||||
{
|
||||
private readonly CheckpointManager _checkpointManager;
|
||||
private readonly IWorkflowExecutionEnvironment _executionEnvironment;
|
||||
private readonly Workflow? _workflow;
|
||||
private readonly Func<CancellationToken, ValueTask<Workflow>>? _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<Workflow>? _cachedWorkflowTask;
|
||||
private readonly ILogger _logger;
|
||||
private readonly ConcurrentDictionary<string, CheckpointInfo> _cursor = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HostedWorkflowState"/> class over a single shared workflow
|
||||
/// instance.
|
||||
/// </summary>
|
||||
/// <param name="workflow">The workflow target.</param>
|
||||
/// <param name="checkpointManager">
|
||||
/// The checkpoint manager to use. Defaults to <see cref="CheckpointManager.CreateInMemory"/> when not provided.
|
||||
/// </param>
|
||||
/// <param name="executionEnvironment">
|
||||
/// The workflow execution environment used to run and resume the workflow. Defaults to an in-process environment
|
||||
/// (<see cref="InProcessExecutionEnvironment"/>) configured with <paramref name="checkpointManager"/>. 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 <paramref name="checkpointManager"/>, since
|
||||
/// the holder reads that manager directly to recover the head checkpoint when its in-memory cursor misses.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">
|
||||
/// The logger factory used to report resume diagnostics (for example, a resume turn that made no progress).
|
||||
/// Defaults to <see cref="NullLoggerFactory"/> when not provided.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="workflow"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// (<see cref="HostedWorkflowState(Func{CancellationToken, ValueTask{Workflow}}, CheckpointManager?, IWorkflowExecutionEnvironment?, ILoggerFactory?, bool)"/>),
|
||||
/// which builds a fresh workflow instance per run.
|
||||
/// </remarks>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HostedWorkflowState"/> class that builds its workflow from a
|
||||
/// factory.
|
||||
/// </summary>
|
||||
/// <param name="workflowFactory">
|
||||
/// 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
|
||||
/// <paramref name="checkpointManager"/>. By default (<paramref name="cacheWorkflow"/> is <see langword="false"/>)
|
||||
/// it is invoked once per run, so independent sessions each get their own instance and run concurrently. When
|
||||
/// <paramref name="cacheWorkflow"/> is <see langword="true"/> it is invoked once, on first use, and the built
|
||||
/// instance is reused for every run.
|
||||
/// </param>
|
||||
/// <param name="checkpointManager">
|
||||
/// The checkpoint manager to use. Defaults to <see cref="CheckpointManager.CreateInMemory"/> when not provided.
|
||||
/// </param>
|
||||
/// <param name="executionEnvironment">
|
||||
/// The workflow execution environment used to run and resume the workflow. Defaults to an in-process environment
|
||||
/// (<see cref="InProcessExecutionEnvironment"/>) configured with <paramref name="checkpointManager"/>. A supplied
|
||||
/// environment must checkpoint into the same store as <paramref name="checkpointManager"/>, since the holder reads
|
||||
/// that manager directly to recover the head checkpoint when its in-memory cursor misses.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">
|
||||
/// The logger factory used to report resume diagnostics (for example, a resume turn that made no progress).
|
||||
/// Defaults to <see cref="NullLoggerFactory"/> when not provided.
|
||||
/// </param>
|
||||
/// <param name="cacheWorkflow">
|
||||
/// When <see langword="false"/> (the default), the factory is invoked once per run, so independent sessions run
|
||||
/// in parallel. When <see langword="true"/>, 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 <see cref="CancellationToken.None"/> 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.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="workflowFactory"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// With the default (uncached) factory, turns are not serialized: independent sessions run in parallel, and
|
||||
/// concurrent turns against the <em>same</em> session id are not serialized either — an application that needs a
|
||||
/// single writer per session owns that coordination.
|
||||
/// </remarks>
|
||||
public HostedWorkflowState(
|
||||
Func<CancellationToken, ValueTask<Workflow>> 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<Workflow> 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<Workflow> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the workflow forward for <paramref name="sessionId"/> 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 <paramref name="input"/>. The new head checkpoint is recorded for the session afterwards.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// <see cref="TurnToken"/> so the turn is driven, matching the fresh-run path.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TInput">The workflow input type.</typeparam>
|
||||
/// <param name="sessionId">The application-selected session id.</param>
|
||||
/// <param name="input">The input to run on this turn (used both when starting a new run and when resuming).</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>The run result, including the events emitted on this turn and the recorded head checkpoint.</returns>
|
||||
public async ValueTask<HostedWorkflowRunResult> RunOrResumeAsync<TInput>(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<HostedWorkflowRunResult> RunOrResumeCoreAsync<TInput>(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<WorkflowEvent> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams the events of a run-or-resume turn as they occur, applying the same restore-then-run semantics as
|
||||
/// <see cref="RunOrResumeAsync{TInput}(string, TInput, CancellationToken)"/>: the first turn runs the workflow
|
||||
/// forward from its start executor, and subsequent turns restore the session's latest checkpoint and run
|
||||
/// forward with <paramref name="input"/>. The session's head checkpoint is recorded when the stream ends,
|
||||
/// including when the consumer abandons enumeration early.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TInput">The workflow input type.</typeparam>
|
||||
/// <param name="sessionId">The application-selected session id.</param>
|
||||
/// <param name="input">The input to run on this turn.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>An asynchronous stream of the <see cref="WorkflowEvent"/>s emitted during this turn.</returns>
|
||||
public async IAsyncEnumerable<WorkflowEvent> RunOrResumeStreamingAsync<TInput>(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<WorkflowEvent> 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);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the recorded head checkpoint for <paramref name="sessionId"/>, if any.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The application-selected session id.</param>
|
||||
/// <param name="checkpoint">When this method returns, the recorded head checkpoint, or <see langword="null"/>.</param>
|
||||
/// <returns><see langword="true"/> when a checkpoint is recorded for the session; otherwise <see langword="false"/>.</returns>
|
||||
/// <remarks>
|
||||
/// Internal cursor-inspection helper used by tests; not part of the public surface.
|
||||
/// </remarks>
|
||||
internal bool TryGetCheckpoint(string sessionId, out CheckpointInfo? checkpoint)
|
||||
{
|
||||
_ = Throw.IfNullOrEmpty(sessionId);
|
||||
return this._cursor.TryGetValue(sessionId, out checkpoint);
|
||||
}
|
||||
}
|
||||
@@ -63,47 +63,54 @@ public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="key">The raw isolation key.</param>
|
||||
/// <returns>The escaped isolation key.</returns>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
private static string EscapeIsolationKey(string key) => key.Replace("\\", "\\\\").Replace(":", "\\:");
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="bareConversationId">The original conversation ID.</param>
|
||||
/// <param name="bareSessionStoreId">The original session store ID.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>
|
||||
/// 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.
|
||||
/// </returns>
|
||||
private async ValueTask<string> GetScopedConversationIdAsync(string bareConversationId, CancellationToken cancellationToken)
|
||||
private async ValueTask<string> 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}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
public override async ValueTask<AgentSession> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Multi-user warning.</strong> This store keys threads by
|
||||
/// <c>(agent.Id, conversationId)</c> only — it has no principal/owner dimension. When
|
||||
/// the conversation identifier originates from the wire (for example, an AG-UI
|
||||
/// <c>(agent.Id, sessionStoreId)</c> only — it has no principal/owner dimension. When
|
||||
/// the session store id originates from the wire (for example, an AG-UI
|
||||
/// <c>RunAgentInput.ThreadId</c> or an A2A <c>contextId</c>), 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<string, JsonElement> _threads = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
public override async ValueTask<AgentSession> 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 sealed class InMemoryAgentSessionStore : AgentSessionStore
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetKey(string conversationId, string agentId) => $"{agentId}:{conversationId}";
|
||||
/// <inheritdoc/>
|
||||
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}";
|
||||
}
|
||||
|
||||
@@ -12,14 +12,20 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
public sealed class NoopAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return agent.CreateSessionAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 @@ public sealed class CheckpointManager : ICheckpointManager
|
||||
|
||||
ValueTask<IEnumerable<CheckpointInfo>> ICheckpointManager.RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent)
|
||||
=> this._impl.RetrieveIndexAsync(sessionId, withParent);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the most recently committed checkpoint for the specified session, or <see langword="null"/>
|
||||
/// when the session has no checkpoints.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session identifier whose latest checkpoint should be retrieved.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// The latest <see cref="CheckpointInfo"/> for <paramref name="sessionId"/>, or <see langword="null"/> when no
|
||||
/// checkpoint has been committed for that session.
|
||||
/// </returns>
|
||||
public async ValueTask<CheckpointInfo?> 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<CheckpointInfo> index = await this._impl.RetrieveIndexAsync(sessionId, withParent: null).ConfigureAwait(false);
|
||||
|
||||
CheckpointInfo? latest = null;
|
||||
foreach (CheckpointInfo info in index)
|
||||
{
|
||||
latest = info;
|
||||
}
|
||||
|
||||
return latest;
|
||||
}
|
||||
}
|
||||
|
||||
+21
-2
@@ -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<CheckpointInfo> 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<CheckpointInfo> OrderedCheckpointIndex { get; } = [];
|
||||
|
||||
private Dictionary<CheckpointInfo, string?> CheckpointParents { get; } = [];
|
||||
private HashSet<CheckpointInfo> CheckpointsWithKnownParent { get; } = [];
|
||||
|
||||
@@ -80,7 +92,11 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
{
|
||||
// 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 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
key = new(sessionId);
|
||||
} while (!this.CheckpointIndex.Add(key));
|
||||
|
||||
this.OrderedCheckpointIndex.Add(key);
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
@@ -164,6 +182,7 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.CheckpointIndex.Remove(key);
|
||||
this.OrderedCheckpointIndex.Remove(key);
|
||||
this.CheckpointParents.Remove(key);
|
||||
this.CheckpointsWithKnownParent.Remove(key);
|
||||
|
||||
@@ -202,7 +221,7 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
{
|
||||
this.CheckDisposed();
|
||||
|
||||
return new(this.CheckpointIndex
|
||||
return new(this.OrderedCheckpointIndex
|
||||
.Where(checkpoint => checkpoint.SessionId == sessionId &&
|
||||
(withParent is null ||
|
||||
!this.CheckpointsWithKnownParent.Contains(checkpoint) ||
|
||||
|
||||
@@ -19,8 +19,14 @@ public interface ICheckpointStore<TStoreObject>
|
||||
/// <param name="withParent">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.</param>
|
||||
/// <returns>A value task representing the asynchronous operation. The result contains a collection of <see
|
||||
/// cref="CheckpointInfo"/> objects associated with the specified session. The collection is empty if no checkpoints are
|
||||
/// found.</returns>
|
||||
/// 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.</returns>
|
||||
/// <remarks>
|
||||
/// 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 <see cref="CheckpointManager"/> 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.
|
||||
/// </remarks>
|
||||
ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -77,7 +77,24 @@ public sealed class StreamingRun : CheckpointableRunBase, IAsyncDisposable
|
||||
CancellationToken cancellationToken = default)
|
||||
=> this.WatchStreamAsync(blockOnPendingRequest: true, cancellationToken);
|
||||
|
||||
internal IAsyncEnumerable<WorkflowEvent> WatchStreamAsync(
|
||||
/// <summary>
|
||||
/// Asynchronously streams workflow events as they occur, with control over how the stream behaves when the
|
||||
/// workflow halts awaiting an external response.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <paramref name="blockOnPendingRequest"/> is <see langword="true"/> (the default behavior of
|
||||
/// <see cref="WatchStreamAsync(CancellationToken)"/>), the stream pauses at a pending-request halt and resumes
|
||||
/// once a response is supplied via <see cref="SendResponseAsync"/>. When it is <see langword="false"/>, the
|
||||
/// stream instead ends at that halt, returning control to the caller — matching the non-streaming
|
||||
/// <see cref="Run"/> behavior, which is useful for hosts that drive their own turn loop.
|
||||
/// </remarks>
|
||||
/// <param name="blockOnPendingRequest"><see langword="true"/> to block at a pending-request halt awaiting a
|
||||
/// response; <see langword="false"/> to end the stream at that halt.</param>
|
||||
/// <param name="cancellationToken">A <see cref="CancellationToken"/> 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.</param>
|
||||
/// <returns>An asynchronous stream of <see cref="WorkflowEvent"/> objects representing significant workflow state changes.</returns>
|
||||
public IAsyncEnumerable<WorkflowEvent> WatchStreamAsync(
|
||||
bool blockOnPendingRequest,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> this._runHandle.TakeEventStreamAsync(blockOnPendingRequest, cancellationToken);
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
|
||||
<NoWarn>$(NoWarn);OPENAI001;</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+96
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Live integration tests for the app-owned routing helper surface (<see cref="OpenAIResponses"/> plus
|
||||
/// <see cref="AgentSessionStore"/>) 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Skipped unless the OpenAI configuration is present (<c>OPENAI_API_KEY</c>), so runs without secrets stay
|
||||
/// green. The in-process, in-memory (no live model) coverage lives in the
|
||||
/// <c>Microsoft.Agents.AI.Hosting.OpenAI.UnitTests</c> project (<c>OpenAIResponsesHostingTests</c>).
|
||||
/// </remarks>
|
||||
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<string> 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();
|
||||
}
|
||||
}
|
||||
+298
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// In-process, in-memory hosting tests for <see cref="OpenAIResponses"/>: a request travels through an
|
||||
/// app-owned ASP.NET Core route (hosted in-memory via <see cref="TestServer"/>) that wires
|
||||
/// <see cref="OpenAIResponses"/> plus an <see cref="AgentSessionStore"/> / <see cref="HostedWorkflowState"/>,
|
||||
/// exactly like the <c>local_responses</c> / <c>local_responses_workflow</c> 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 <c>Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests</c>
|
||||
/// project.
|
||||
/// </summary>
|
||||
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<string> 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<HttpClient> 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<HttpClient> 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<IServer>() 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="OpenAIResponses"/> helper facade.
|
||||
/// </summary>
|
||||
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<ArgumentException>("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<ArgumentException>("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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a minimal human-in-the-loop workflow whose start executor forwards its input to a
|
||||
/// <see cref="RequestPort"/>, so the workflow emits a <see cref="RequestInfoEvent"/> and halts awaiting an
|
||||
/// external response. Used to verify that resuming such a workflow does not block indefinitely.
|
||||
/// </summary>
|
||||
internal static class ApprovalGateWorkflow
|
||||
{
|
||||
internal const string RequestPortId = "approval";
|
||||
|
||||
internal static Workflow Build()
|
||||
{
|
||||
var gate = new ApprovalGateExecutor("gate", RequestPortId);
|
||||
RequestPort port = RequestPort.Create<ApprovalRequest, string>(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<string>(this.HandleAsync))
|
||||
.SendsMessage<ApprovalRequest>();
|
||||
|
||||
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<string>(this.HandleAsync))
|
||||
.YieldsOutput<string>();
|
||||
|
||||
private ValueTask HandleAsync(string approval, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
=> context.YieldOutputAsync($"approved:{approval}", cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a workflow whose start executor accepts a typed <see cref="WriterBrief"/> (not
|
||||
/// <c>List<ChatMessage></c>) 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
|
||||
/// <c>RunOrResumeAsync<TInput></c>.
|
||||
/// </summary>
|
||||
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<WriterBrief>(this.HandleAsync))
|
||||
.YieldsOutput<string>();
|
||||
|
||||
private ValueTask HandleAsync(WriterBrief brief, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
=> context.YieldOutputAsync($"[{brief.Style}] {brief.Topic}", cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// A minimal <see cref="ILoggerFactory"/> that captures every log entry it produces, for asserting on
|
||||
/// diagnostics emitted by the system under test.
|
||||
/// </summary>
|
||||
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>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
=> entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a non-chat-protocol workflow whose single executor keeps a running count in workflow state and
|
||||
/// yields <c>count:N</c>. Because the count is checkpointed, a genuine resume observes the accumulated value
|
||||
/// (for example <c>count:2</c> on the second turn), whereas a fresh run restarts at <c>count:1</c>. Used to
|
||||
/// prove that a turn actually resumed from a prior checkpoint rather than starting over.
|
||||
/// </summary>
|
||||
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<string>(this.HandleAsync))
|
||||
.YieldsOutput<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -387,11 +387,14 @@ public class DelegatingAgentSessionStoreTests
|
||||
/// </summary>
|
||||
private sealed class ConcreteAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
public override ValueTask<AgentSession> 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a workflow whose start executor, in a single superstep, both emits an external request (to a
|
||||
/// <see cref="RequestPort"/>) 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.
|
||||
/// </summary>
|
||||
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<ApprovalRequest, string>(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<string>(this.HandleAsync))
|
||||
.SendsMessage<ApprovalRequest>()
|
||||
.SendsMessage<string>();
|
||||
|
||||
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<string>(this.HandleAsync))
|
||||
.YieldsOutput<string>();
|
||||
|
||||
private ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
=> context.YieldOutputAsync($"{DownstreamPrefix}{message}", cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string>(this.HandleAsync))
|
||||
.YieldsOutput<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="HostedWorkflowState"/> class.
|
||||
/// </summary>
|
||||
public class HostedWorkflowStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_NullWorkflow_Throws() =>
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("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<ArgumentException>(() => state.RunOrResumeAsync(sessionId!, "input").AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOrResumeAsync_NullInput_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var state = new HostedWorkflowState(CreateTestWorkflow());
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>("input", () => state.RunOrResumeAsync<string>("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<ArgumentNullException>("workflowFactory", () => new HostedWorkflowState((Func<CancellationToken, ValueTask<Workflow>>)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<Workflow>(GatedCountingWorkflow.Build(entered, release.Task)),
|
||||
CheckpointManager.CreateInMemory());
|
||||
|
||||
// Act: start two turns for DIFFERENT sessions.
|
||||
Task<HostedWorkflowRunResult> first = state.RunOrResumeAsync("s1", "go").AsTask();
|
||||
Task<HostedWorkflowRunResult> 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<Workflow>(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<Workflow>(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<Workflow>(Task.FromException<Workflow>(new InvalidOperationException("transient build failure")));
|
||||
}
|
||||
|
||||
return new ValueTask<Workflow>(CountingWorkflow.Build());
|
||||
},
|
||||
cacheWorkflow: true);
|
||||
|
||||
// Act & Assert: the first run surfaces the build failure.
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => 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<Workflow>(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<WorkflowEvent> 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<WorkflowEvent> 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<WorkflowOutputEvent>()
|
||||
.Select(e => e.Data)
|
||||
.OfType<IEnumerable<ChatMessage>>()
|
||||
.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<ChatMessage>.
|
||||
// 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<ChatMessage> InputMessages(string text) => [new(ChatRole.User, text)];
|
||||
|
||||
private static string OutputText(HostedWorkflowRunResult result) =>
|
||||
string.Concat(
|
||||
result.Events
|
||||
.OfType<WorkflowOutputEvent>()
|
||||
.Select(e => e.Data)
|
||||
.OfType<IEnumerable<ChatMessage>>()
|
||||
.SelectMany(messages => messages)
|
||||
.Select(m => m.Text));
|
||||
|
||||
private static string StringOutput(HostedWorkflowRunResult result) =>
|
||||
string.Concat(
|
||||
result.Events
|
||||
.OfType<WorkflowOutputEvent>()
|
||||
.Select(e => e.Data)
|
||||
.OfType<string>());
|
||||
|
||||
private static Workflow CreateEchoWorkflow() =>
|
||||
AgentWorkflowBuilder.BuildSequential(workflowName: "echo", agents: [new TestEchoAgent(name: "echo")]);
|
||||
|
||||
private static Workflow CreateTestWorkflow()
|
||||
{
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
mockAgent.Setup(a => a.Name).Returns("testAgent");
|
||||
return AgentWorkflowBuilder.BuildSequential(workflowName: "wf", agents: [mockAgent.Object]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AgentSessionStore.DeleteSessionAsync"/> across the in-box stores.
|
||||
/// </summary>
|
||||
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<AIAgent>();
|
||||
agent.Protected()
|
||||
.Setup<ValueTask<JsonElement>>("SerializeSessionCoreAsync", ItExpr.IsAny<AgentSession>(), ItExpr.IsAny<JsonSerializerOptions>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask<JsonElement>(stored));
|
||||
agent.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("DeserializeSessionCoreAsync", ItExpr.IsAny<JsonElement>(), ItExpr.IsAny<JsonSerializerOptions>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask<AgentSession>(restoredSession));
|
||||
agent.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask<AgentSession>(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<AIAgent>().Object, "missing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSessionAsync_NoopStore_CompletesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new NoopAgentSessionStore();
|
||||
|
||||
// Act & Assert (no exception)
|
||||
await store.DeleteSessionAsync(new Mock<AIAgent>().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<NotSupportedException>(() => store.DeleteSessionAsync(new Mock<AIAgent>().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<string>("marker"));
|
||||
Assert.Equal("v1", branchB.StateBag.GetValue<string>("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<string>("marker"));
|
||||
|
||||
AgentSession branchC = await store.GetSessionAsync(agent, "s1");
|
||||
Assert.Equal("v1", branchC.StateBag.GetValue<string>("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<AgentSession> 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<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null) => null;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -419,11 +419,14 @@ public class IsolationKeyScopedAgentSessionStoreTests
|
||||
/// </summary>
|
||||
private sealed class ConcreteAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
public override ValueTask<AgentSession> 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a minimal non-chat-protocol workflow whose single executor accepts a <see cref="string"/> and yields
|
||||
/// it back as output. Used to exercise the resume path for workflows whose start executor does not accept
|
||||
/// <c>List<ChatMessage></c> (so no <see cref="TurnToken"/> is sent).
|
||||
/// </summary>
|
||||
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<string>(this.HandleAsync))
|
||||
.YieldsOutput<string>();
|
||||
|
||||
private ValueTask HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
=> context.YieldOutputAsync($"echo:{input}", cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic <see cref="AIAgent"/> 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.
|
||||
/// </summary>
|
||||
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<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return serializedState.Deserialize<EchoAgentSession>(jsonSerializerOptions) ?? await this.CreateSessionAsync(cancellationToken);
|
||||
}
|
||||
|
||||
protected override ValueTask<JsonElement> 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<AgentSession> 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<ChatMessage> EchoMessages(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null)
|
||||
{
|
||||
List<ChatMessage> 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<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> 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<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> 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) { }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="CheckpointManager.GetLatestCheckpointAsync"/>, which must return the most recently
|
||||
/// committed checkpoint for a session regardless of the backing store implementation.
|
||||
/// </summary>
|
||||
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<CheckpointInfo> 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<CheckpointInfo> 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]);
|
||||
}
|
||||
}
|
||||
+50
@@ -160,6 +160,56 @@ public sealed class FileSystemJsonCheckpointStoreTests
|
||||
#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 = "/:";
|
||||
|
||||
Reference in New Issue
Block a user