.NET: Add support for Resilient long-running and Steerable Foundry Hosted Agents (#7370)
* feat(foundry): add resilient background hosting Enable AgentServer recovery and steering through FoundryResponsesOptions. Persist AgentSession snapshots during long background turns while workflow checkpointing remains owned by the workflow runtime. * feat(foundry): complete resilient and steerable hosting * fix(foundry): address resilience review feedback * feat(foundry): align resilient workflow checkpoints * docs(foundry): update resilience review guidance
This commit is contained in:
@@ -207,6 +207,10 @@ temp*/
|
||||
|
||||
# AI
|
||||
**/.checkpoints/
|
||||
# Local AgentServer file store + crash-recovery HOME roots used by hosted samples
|
||||
**/.agentserver-state/
|
||||
**/.agentserver-state-*/
|
||||
**/.home-*/
|
||||
.claude/
|
||||
.omc/
|
||||
.omx/
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
status: proposed
|
||||
contact: rogerbarreto
|
||||
date: 2026-08-21
|
||||
deciders: rogerbarreto
|
||||
consulted: Tao Chen, Sergey M., Ben Thomas, Shanmukha
|
||||
informed: Agent Framework .NET team
|
||||
---
|
||||
|
||||
# Resilient long-running agents in Microsoft.Agents.AI.Foundry.Hosting
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
The Foundry Hosted Agents platform can run a hosted agent as a long job that continues when no
|
||||
client is connected, and that the platform restarts after the container crashes or is recycled.
|
||||
On restart the platform re-invokes the handler with the same input, sets `ResponseContext.IsRecovery`
|
||||
to true, and supplies the last durable `ResponseObject` snapshot as `PersistedResponse`. The
|
||||
snapshot is not itself a workflow checkpoint. For workflow agents, hosting records the ID of the
|
||||
matching workflow checkpoint inside AgentServer internal response metadata before it persists the
|
||||
response snapshot.
|
||||
|
||||
This applies only to **background** requests (`background=true`) whose `store` value is omitted or
|
||||
true. Omitted `store` uses the Responses API default of true. Foreground requests and explicit
|
||||
`store=false` requests have no crash-recovery contract.
|
||||
|
||||
Python currently supports resilient background execution for workflow agents and steering for
|
||||
single agents. .NET hosting must offer the same opt-in capabilities on top of the durable session
|
||||
and checkpoint storage introduced for Foundry state stores (PR #7649).
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Match the Python recovery contract.
|
||||
- Pair each persisted workflow response snapshot with the exact workflow checkpoint it represents.
|
||||
- Opt-in and off by default; non-resilient hosts pay nothing.
|
||||
- Prefer workflows: they already checkpoint between supersteps.
|
||||
- Keep a lean API on `FoundryResponsesOptions`, forwarded to `ResponsesServerOptions`.
|
||||
- Persist agent sessions through the Foundry state store (or its local fallback), not a second disk layout.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: **turn resilience on through the existing handler and registration path**.
|
||||
|
||||
### Public surface
|
||||
|
||||
`FoundryResponsesOptions.ResilientBackground` and `FoundryResponsesOptions.SteerableConversations`
|
||||
are forwarded to `ResponsesServerOptions` so the AgentServer SDK enables recovery and steering.
|
||||
This forwarding must happen in the callback passed directly to `AddResponsesServer`. The SDK makes
|
||||
two process-level choices during that registration call: whether local SSE replay uses durable
|
||||
storage and whether the conversation task accepts steering. Configuring the options only through
|
||||
the later `IOptions` pipeline is too late for those choices.
|
||||
|
||||
The first `AddFoundryResponses` call owns this host-level configuration. Repeated calls do not
|
||||
register another Responses server or redefine its resilience mode. Later calls can still configure
|
||||
MAF-only options such as `AllowStoredOutputEnabled`; attempting to enable an AgentServer task
|
||||
feature after the first call fails immediately instead of leaving AgentServer and MAF with
|
||||
different settings.
|
||||
|
||||
```csharp
|
||||
builder.Services.AddFoundryResponses(agent, configure: o => o.ResilientBackground = true);
|
||||
```
|
||||
|
||||
### Handler contract on recovery
|
||||
|
||||
When `IsRecovery` is true:
|
||||
|
||||
1. Seed `ResponseEventStream` from the `PersistedResponse` that AgentServer provides. This preserves
|
||||
its response fields, completed output items, and internal metadata.
|
||||
2. When the snapshot contains `_last_checkpoint_id` and a persisted workflow `AgentSession` was
|
||||
restored, select that exact checkpoint as the workflow resume point. This prevents a newer
|
||||
checkpoint already present in workflow storage from being combined with an older response
|
||||
snapshot. Foundry Hosting obtains the experimental `WorkflowSessionCheckpointRecovery` service
|
||||
from the restored `AgentSession`; the internal `WorkflowSession` remains hidden. The resumed run
|
||||
continues the work already queued in that checkpoint without sending a new `TurnToken` to the
|
||||
start executor.
|
||||
3. When `_last_checkpoint_id` is absent, retain the checkpoint already referenced by the restored
|
||||
session. This covers a crash after the workflow wrote its first checkpoint but before AgentServer
|
||||
persisted the first paired response snapshot. If the process stopped before the first session
|
||||
save, no resumable MAF state exists, so the handler re-injects the original input instead of
|
||||
invoking a fresh session with no messages. A regular agent has no equivalent within-turn workflow
|
||||
checkpoint, so recovery remains best-effort and depends on its serialized session state.
|
||||
4. On graceful shutdown of a resilient turn, call `ExitForRecoveryAsync` instead of emitting
|
||||
incomplete. The AgentServer shutdown token is linked to the token passed into the MAF agent so
|
||||
long-running model, tool, and workflow operations stop promptly. The handler also checks
|
||||
`IsShutdownRequested` after each agent update, because an agent may consume cancellation and
|
||||
return normally instead of throwing. If shutdown becomes visible after the agent advanced but
|
||||
before the corresponding event was emitted, the final session save is skipped. Recovery uses
|
||||
the last session snapshot that corresponds to output already handed to AgentServer.
|
||||
5. For non-workflow agents, best-effort save the agent session after each
|
||||
`ResponseOutputItemDoneEvent`, with an authoritative end-of-turn save in `finally` (skipped when
|
||||
the turn failed). Workflow agents use only the paired superstep path below for incremental saves,
|
||||
so their persisted session cannot advance independently through ordinary output-item saves.
|
||||
|
||||
### Workflow response checkpoint alignment
|
||||
|
||||
When `OutputConverter` receives a `SuperStepCompletedEvent` with a new workflow checkpoint ID:
|
||||
|
||||
1. Close any response output item still open for that superstep.
|
||||
2. Compare the new ID with `_last_checkpoint_id` in `ResponseEventStream.InternalMetadata`. If they
|
||||
match, do nothing.
|
||||
3. Save the `AgentSession` that references the new workflow checkpoint. If this save fails, keep the
|
||||
prior response snapshot and metadata. The turn continues, and a later workflow checkpoint or the
|
||||
final save can try again.
|
||||
4. Write the new ID to `_last_checkpoint_id`.
|
||||
5. Emit `response.in_progress` with the updated response state. AgentServer beta.8 tracks a
|
||||
separate authoritative response object, so this event copies the internal metadata into the
|
||||
snapshot that its checkpoint operation persists. The reserved metadata remains stripped from
|
||||
client payloads.
|
||||
6. Yield `ResponseEventStream.Checkpoint()`. AgentServer persists the response snapshot before it
|
||||
resumes the handler.
|
||||
|
||||
The workflow checkpoint itself is already durable before `SuperStepCompletedEvent` is emitted. The
|
||||
session save and response checkpoint therefore establish a recoverable boundary with three matching
|
||||
parts: completed response output, serialized session state, and workflow checkpoint ID.
|
||||
|
||||
If a crash occurs after the workflow creates a newer checkpoint but before the next response
|
||||
checkpoint, recovery deliberately uses the older ID from `PersistedResponse`. The workflow may
|
||||
repeat work after that older boundary, but it does not duplicate output already present in the
|
||||
response snapshot or lose output by resuming ahead of it.
|
||||
|
||||
### Handler contract on steering
|
||||
|
||||
When a second input arrives for an active steerable conversation:
|
||||
|
||||
1. AgentServer returns a response with `status=queued`, records the input, increments
|
||||
`PendingInputCount` on the active handler context, and signals that handler's cancellation token.
|
||||
2. The superseded handler invocation has `IsSteeredTurn=false`. If a cancellation-aware MAF
|
||||
operation throws `OperationCanceledException`, Foundry Hosting uses `PendingInputCount > 0` to
|
||||
distinguish steering from shutdown and client cancellation.
|
||||
3. Foundry Hosting completes the superseded response cleanly and saves its `AgentSession` with a
|
||||
non-cancelled save token. This gives the queued turn the latest committed MAF state.
|
||||
4. AgentServer invokes the handler again with `IsSteeredTurn=true`. This is not crash recovery:
|
||||
`IsRecovery=false`, so the new input is converted to MAF messages normally. The same
|
||||
`conversation_id` resolves the same persisted `AgentSession`.
|
||||
|
||||
No special MAF branch is required merely because `IsSteeredTurn=true`. The classification is
|
||||
available for handlers that need different application behavior; the generic adapter treats the
|
||||
drained input as the next normal turn on the same session.
|
||||
|
||||
Steering does not create a response checkpoint merely because another input was queued. Completed
|
||||
workflow supersteps have already been paired with response checkpoints. An interrupted superstep
|
||||
has no new `SuperStepCompletedEvent`, so its partial output and session state do not advance the
|
||||
paired recovery boundary. The superseded response still reaches a terminal `completed` event.
|
||||
|
||||
### State ownership
|
||||
|
||||
| State | Owner | Recovery purpose |
|
||||
|---|---|---|
|
||||
| Resilient task, SSE events, `ResponseObject` snapshots, `_last_checkpoint_id` | AgentServer | Re-invoke the handler and identify the workflow checkpoint represented by each response snapshot |
|
||||
| Serialized `AgentSession` | Foundry Hosting | Restore agent-owned state and the workflow checkpoint reference |
|
||||
| Workflow execution checkpoints | Workflow runtime through `FoundryJsonCheckpointStore` | Restore executors, queued messages, pending requests, and workflow state |
|
||||
|
||||
The handler calls `ResponseEventStream.Checkpoint()` only after a workflow superstep supplies a new
|
||||
checkpoint ID and the matching `AgentSession` save succeeds. `PersistedResponse.Output.Count` is not
|
||||
the workflow cursor. `_last_checkpoint_id` is the explicit link between the response snapshot and
|
||||
workflow storage.
|
||||
|
||||
### Relationship to durable storage (PR #7649)
|
||||
|
||||
Sessions and workflow checkpoints already go through `FoundryAgentSessionStore` /
|
||||
`FoundryJsonCheckpointStore`. AgentServer separately owns resilient task records, response snapshots,
|
||||
and SSE event replay. Resilience does not invent another store; it coordinates handler re-entry with
|
||||
the existing session and workflow stores.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Samples: `Hosted-Workflow-Resilient`, `Hosted-Workflow-Resilient-Long-Running`, and
|
||||
`Hosted-Steering`.
|
||||
- `Using-E2E-Resilience` runs the complete local crash-recovery demonstration in one console:
|
||||
it consumes the server through a MAF agent created by `AIProjectClient`, force-kills the process,
|
||||
restarts it, reconnects with a sequence-aware `ResponseContinuationToken`, then uses a third call
|
||||
on the same agent and session without a sequence cursor to replay the full stream. It validates
|
||||
the exact final countdown against the client accumulator and cursor-free replay.
|
||||
- Handler-level tests cover recovery input skip, consumption of an available response snapshot,
|
||||
response checkpoint deduplication by workflow checkpoint ID, and session-save failure that keeps
|
||||
the prior paired boundary.
|
||||
- A local two-lifetime integration test starts a real Responses host, persists a MAF
|
||||
`AgentSession`, stops the host, starts a new host over the same local AgentServer state, and
|
||||
verifies that the same response completes without re-injecting the original input.
|
||||
- A deterministic countdown recovery test interrupts a workflow after outputs `6`, `5`, and `4`,
|
||||
starts a new host, and verifies the final output is exactly `6`, `5`, `4`, `3`, `2`, `1`,
|
||||
`Countdown complete.` with no missing or duplicated items.
|
||||
- A local steering integration test sends two real HTTP turns through AgentServer and the MAF
|
||||
adapter. It verifies `queued`, serial execution, delivery of the steering input, and reuse of the
|
||||
persisted session.
|
||||
- Live Foundry tests cover background continuation without client traffic, hard process
|
||||
termination through `Environment.Exit`, recovery in a different process incarnation, transient
|
||||
`404`/`424` polling responses during replacement, and long-running steering on the same
|
||||
conversation.
|
||||
- The checkpoint-index optimistic-concurrency retry count is configurable through
|
||||
`FoundryJsonCheckpointStore`, with a default of eight attempts.
|
||||
- Package floor: Azure.AI.AgentServer Core beta.28, Invocations beta.6, Responses beta.8.
|
||||
@@ -377,11 +377,23 @@
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient/HostedWorkflowResilient.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/HostedWorkflowResilientLongRunning.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Steering/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Steering/HostedSteering.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/Hosted-Toolbox-AuthPaths-Client/Hosted-Toolbox-AuthPaths-Client.csproj" />
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/SessionFilesClient.csproj" />
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/SimpleAgent.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/Using-E2E-Resilience.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/HostedWorkflowHandoff.csproj" />
|
||||
</Folder>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# azd tooling files
|
||||
azure.yaml
|
||||
.agentignore
|
||||
|
||||
# Security / secrets
|
||||
.env
|
||||
.env.*
|
||||
.azure/
|
||||
.git/
|
||||
|
||||
# .NET build output
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
|
||||
# Local agent state
|
||||
.checkpoints/
|
||||
.agentserver-state/
|
||||
.agentserver-state-*/
|
||||
.home-*/
|
||||
@@ -0,0 +1,9 @@
|
||||
# Foundry project endpoint
|
||||
FOUNDRY_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
|
||||
|
||||
# Model deployment name
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
|
||||
# Local development only
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
AZURE_TOKEN_CREDENTIALS=dev
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<ImportDirectoryPackagesProps>false</ImportDirectoryPackagesProps>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk.Web" />
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TargetFrameworks></TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>HostedSteering</RootNamespace>
|
||||
<AssemblyName>HostedSteering</AssemblyName>
|
||||
<UserSecretsId>8197fe92-5ccf-45fd-ab1e-f45755ef3a48</UserSecretsId>
|
||||
<AgentFrameworkVersion>1.18.0-preview.260818.1</AgentFrameworkVersion>
|
||||
<LocalAgentFrameworkRoot>$(MSBuildThisFileDirectory)..\..\..\..\..\src</LocalAgentFrameworkRoot>
|
||||
<UseLocalAgentFramework Condition="'$(UseLocalAgentFramework)' == '' and Exists('$(LocalAgentFrameworkRoot)\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj')">true</UseLocalAgentFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="'$(UseLocalAgentFramework)' == 'true'">
|
||||
<ProjectReference Include="$(LocalAgentFrameworkRoot)\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="$(LocalAgentFrameworkRoot)\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(UseLocalAgentFramework)' != 'true'">
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="$(AgentFrameworkVersion)" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="$(AgentFrameworkVersion)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" Version="2.1.0-beta.4" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.21.0" />
|
||||
<PackageReference Include="DotNetEnv" Version="3.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk.Web" />
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Sample: a Foundry Hosted Agent that accepts steering input while a response is still running.
|
||||
// It deploys directly from source, so Foundry builds and runs the uploaded project.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
Env.TraversePath().Load();
|
||||
|
||||
var projectEndpoint = new Uri(System.Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
|
||||
var deployment = FirstNonBlank(
|
||||
System.Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME"),
|
||||
System.Environment.GetEnvironmentVariable("FOUNDRY_MODEL"),
|
||||
"gpt-4o");
|
||||
var agentName = System.Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-steering";
|
||||
|
||||
AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential())
|
||||
.AsAIAgent(
|
||||
model: deployment,
|
||||
instructions: """
|
||||
You are a helpful AI assistant. When another message arrives while you are working,
|
||||
treat it as a course correction and incorporate it into the answer.
|
||||
""",
|
||||
name: agentName,
|
||||
description: "A steerable general-purpose AI assistant");
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent, configure: options => options.SteerableConversations = true);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
app.Run();
|
||||
|
||||
static string FirstNonBlank(params string?[] candidates) =>
|
||||
Array.Find(candidates, candidate => !string.IsNullOrWhiteSpace(candidate))!;
|
||||
@@ -0,0 +1,76 @@
|
||||
# Hosted-Steering
|
||||
|
||||
A Foundry Hosted Agent with steerable conversations enabled. When a second input arrives while a
|
||||
conversation turn is running, AgentServer queues it instead of returning `conversation_locked`.
|
||||
|
||||
This sample deploys directly from source. Foundry uploads the project as a ZIP, restores its
|
||||
packages, builds it, and runs `HostedSteering.dll`. No Dockerfile or container registry is needed.
|
||||
|
||||
## Key setting
|
||||
|
||||
```csharp
|
||||
builder.Services.AddFoundryResponses(
|
||||
agent,
|
||||
configure: options => options.SteerableConversations = true);
|
||||
```
|
||||
|
||||
Steering and resilient background execution are separate options. This sample enables only
|
||||
steering. See [Hosted-Workflow-Resilient](../Hosted-Workflow-Resilient/README.md) for crash recovery.
|
||||
|
||||
## Local development
|
||||
|
||||
Copy `.env.example` to `.env`, set the project endpoint and model deployment, then run:
|
||||
|
||||
```powershell
|
||||
az login
|
||||
dotnet run --tl:off
|
||||
```
|
||||
|
||||
The in-repository project automatically uses ProjectReference to run the current framework source.
|
||||
|
||||
## Deploy from source
|
||||
|
||||
Create an empty working directory outside the repository:
|
||||
|
||||
```powershell
|
||||
$work = Join-Path $env:TEMP "hosted-steering-work"
|
||||
New-Item -ItemType Directory -Path $work -Force | Out-Null
|
||||
Set-Location $work
|
||||
|
||||
$sample = "<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Steering/azure.yaml"
|
||||
azd auth login
|
||||
azd ai agent init -m $sample -d <model-deployment>
|
||||
```
|
||||
|
||||
### Contributors testing framework changes
|
||||
|
||||
**Skip this section unless you are testing an Agent Framework change from the current codebase that
|
||||
has not been released yet.** The normal deployment uses the published packages. To test local
|
||||
framework changes, pack the current repository source into the scaffolded upload before provisioning:
|
||||
|
||||
```powershell
|
||||
<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/Add-LocalFrameworkFeed.ps1 `
|
||||
-Path ./hosted-steering
|
||||
```
|
||||
|
||||
The helper creates `local-feed/`, writes `nuget.config`, and changes `AgentFrameworkVersion` in the
|
||||
scaffolded project. Both generated artifacts are included in the source ZIP.
|
||||
|
||||
```powershell
|
||||
Set-Location hosted-steering
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME <model-deployment>
|
||||
azd provision
|
||||
azd deploy
|
||||
```
|
||||
|
||||
## Exercise steering
|
||||
|
||||
Start a stored background response, keep its response or conversation identity, then submit a second
|
||||
input to the same in-progress conversation. The second request should be queued instead of rejected.
|
||||
Use the Responses API or an OpenAI-compatible client that exposes background and conversation fields.
|
||||
|
||||
## Related samples
|
||||
|
||||
- [Hosted-ChatClientAgent](../Hosted-ChatClientAgent/README.md): basic source-deployed agent.
|
||||
- [Hosted-Workflow-Resilient](../Hosted-Workflow-Resilient/README.md): resilient background workflow.
|
||||
- [Hosted-Workflow-Resilient-Long-Running](../Hosted-Workflow-Resilient-Long-Running/README.md): deterministic countdown recovery with exact output validation.
|
||||
@@ -0,0 +1,36 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
|
||||
|
||||
name: hosted-steering
|
||||
services:
|
||||
ai-project:
|
||||
host: azure.ai.project
|
||||
hosted-steering:
|
||||
project: .
|
||||
host: azure.ai.agent
|
||||
language: csharp
|
||||
uses:
|
||||
- ai-project
|
||||
codeConfiguration:
|
||||
dependencyResolution: remote_build
|
||||
entryPoint: HostedSteering.dll
|
||||
runtime: dotnet_10
|
||||
env:
|
||||
ASPNETCORE_URLS: http://+:8088
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
|
||||
container:
|
||||
resources:
|
||||
cpu: "0.5"
|
||||
memory: 1Gi
|
||||
description: |
|
||||
A Foundry Hosted Agent that accepts steering input while a response is still running.
|
||||
kind: hosted
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Agent Framework
|
||||
- Steering
|
||||
name: hosted-steering
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 2.0.0
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# azd tooling files
|
||||
azure.yaml
|
||||
.agentignore
|
||||
|
||||
# Security / secrets
|
||||
.env
|
||||
.env.*
|
||||
.azure/
|
||||
.git/
|
||||
|
||||
# .NET build output
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
|
||||
# Local agent state
|
||||
.checkpoints/
|
||||
.agentserver-state/
|
||||
.agentserver-state-*/
|
||||
.home-*/
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
# Optional local countdown delay
|
||||
COUNTDOWN_DELAY_SECONDS=1
|
||||
|
||||
# Local development only
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<ImportDirectoryPackagesProps>false</ImportDirectoryPackagesProps>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk.Web" />
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TargetFrameworks></TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>HostedWorkflowResilientLongRunning</RootNamespace>
|
||||
<AssemblyName>HostedWorkflowResilientLongRunning</AssemblyName>
|
||||
<UserSecretsId>440f42c9-f64e-441e-9d92-ea814203075e</UserSecretsId>
|
||||
<AgentFrameworkVersion>1.18.0-preview.260818.1</AgentFrameworkVersion>
|
||||
<LocalAgentFrameworkRoot>$(MSBuildThisFileDirectory)..\..\..\..\..\src</LocalAgentFrameworkRoot>
|
||||
<UseLocalAgentFramework Condition="'$(UseLocalAgentFramework)' == '' and Exists('$(LocalAgentFrameworkRoot)\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj')">true</UseLocalAgentFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="'$(UseLocalAgentFramework)' == 'true'">
|
||||
<ProjectReference Include="$(LocalAgentFrameworkRoot)\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="$(LocalAgentFrameworkRoot)\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(UseLocalAgentFramework)' != 'true'">
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="$(AgentFrameworkVersion)" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="$(AgentFrameworkVersion)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DotNetEnv" Version="3.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk.Web" />
|
||||
|
||||
</Project>
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Sample: a long-running countdown workflow hosted as a resilient background response.
|
||||
// Each completed superstep is paired with an AgentServer response checkpoint so a restarted
|
||||
// process resumes with ordered output and without losing or duplicating countdown items.
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using DotNetEnv;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
Env.TraversePath().Load();
|
||||
|
||||
var agentName = System.Environment.GetEnvironmentVariable("AGENT_NAME")
|
||||
?? "hosted-workflow-resilient-long-running";
|
||||
var delaySeconds = int.TryParse(
|
||||
System.Environment.GetEnvironmentVariable("COUNTDOWN_DELAY_SECONDS"),
|
||||
NumberStyles.None,
|
||||
CultureInfo.InvariantCulture,
|
||||
out int configuredDelaySeconds)
|
||||
? configuredDelaySeconds
|
||||
: 1;
|
||||
if (delaySeconds < 0)
|
||||
{
|
||||
throw new InvalidOperationException("COUNTDOWN_DELAY_SECONDS must be zero or greater.");
|
||||
}
|
||||
|
||||
var start = new CountdownStartExecutor();
|
||||
var countdown = new CountdownExecutor(TimeSpan.FromSeconds(delaySeconds));
|
||||
var complete = new CountdownCompleteExecutor();
|
||||
|
||||
Workflow workflow = new WorkflowBuilder(start)
|
||||
.AddEdge(start, countdown)
|
||||
.AddEdge(countdown, countdown)
|
||||
.AddEdge(countdown, complete)
|
||||
.WithOutputFrom(start, countdown, complete)
|
||||
.Build();
|
||||
|
||||
AIAgent agent = workflow.AsAIAgent(
|
||||
id: agentName,
|
||||
name: agentName,
|
||||
includeExceptionDetails: true,
|
||||
includeWorkflowOutputsInResponse: true);
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(
|
||||
agent,
|
||||
configure: options => options.ResilientBackground = true);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
}
|
||||
|
||||
Console.WriteLine($"Process ID: {System.Environment.ProcessId}");
|
||||
app.Run();
|
||||
|
||||
[SendsMessage(typeof(int))]
|
||||
[YieldsOutput(typeof(string))]
|
||||
internal sealed partial class CountdownStartExecutor() : ChatProtocolExecutor(
|
||||
"start",
|
||||
new ChatProtocolExecutorOptions { AutoSendTurnToken = false })
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
|
||||
base.ConfigureProtocol(protocolBuilder).SendsMessage<int>();
|
||||
|
||||
protected override async ValueTask TakeTurnAsync(
|
||||
List<ChatMessage> messages,
|
||||
IWorkflowContext context,
|
||||
bool? emitEvents,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string input = string.Join(
|
||||
System.Environment.NewLine,
|
||||
messages.Select(message => message.Text).Where(text => !string.IsNullOrWhiteSpace(text)));
|
||||
Match match = PositiveIntegerRegex().Match(input);
|
||||
if (!match.Success
|
||||
|| !int.TryParse(match.Value, NumberStyles.None, CultureInfo.InvariantCulture, out int target)
|
||||
|| target <= 0)
|
||||
{
|
||||
await context.YieldOutputAsync(
|
||||
"The message must contain a positive integer counter target.",
|
||||
cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await context.SendMessageAsync(target, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"(?<!\d)\d+(?!\d)", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex PositiveIntegerRegex();
|
||||
}
|
||||
|
||||
[SendsMessage(typeof(int))]
|
||||
[SendsMessage(typeof(string))]
|
||||
[YieldsOutput(typeof(string))]
|
||||
internal sealed class CountdownExecutor(TimeSpan delay) : Executor<int>("countdown")
|
||||
{
|
||||
public override async ValueTask HandleAsync(
|
||||
int message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (message <= 0)
|
||||
{
|
||||
await context.SendMessageAsync(
|
||||
"Countdown complete.",
|
||||
targetId: "complete",
|
||||
cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(delay, cancellationToken);
|
||||
await context.YieldOutputAsync(
|
||||
message.ToString(CultureInfo.InvariantCulture),
|
||||
cancellationToken);
|
||||
await context.SendMessageAsync(
|
||||
message - 1,
|
||||
targetId: "countdown",
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
[YieldsOutput(typeof(string))]
|
||||
internal sealed class CountdownCompleteExecutor() : Executor<string>("complete")
|
||||
{
|
||||
public override ValueTask HandleAsync(
|
||||
string message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
context.YieldOutputAsync(message, cancellationToken);
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
# Hosted-Workflow-Resilient-Long-Running
|
||||
|
||||
A deterministic countdown workflow that demonstrates resilient background execution. Each number is
|
||||
one workflow output item. If the process stops, AgentServer restores the last response snapshot and
|
||||
the workflow resumes from the exact workflow checkpoint ID recorded in that snapshot.
|
||||
|
||||
For an input such as `Count down from 6`, the final message outputs are:
|
||||
|
||||
```text
|
||||
6
|
||||
5
|
||||
4
|
||||
3
|
||||
2
|
||||
1
|
||||
Countdown complete.
|
||||
```
|
||||
|
||||
The exact list makes recovery errors visible. A missing item means response state advanced beyond
|
||||
workflow state. A repeated item means the workflow resumed before the response snapshot boundary.
|
||||
|
||||
## Workflow
|
||||
|
||||
| Executor | Behavior |
|
||||
| --- | --- |
|
||||
| `start` | Reads the first positive integer from the request. |
|
||||
| `countdown` | Waits, yields the current number, decrements it, and sends it back to itself. |
|
||||
| `complete` | Yields `Countdown complete.` after the counter reaches zero. |
|
||||
|
||||
All executor IDs and the workflow agent ID are stable so a replacement process reconstructs the same
|
||||
workflow topology.
|
||||
|
||||
## Recovery boundary
|
||||
|
||||
At every completed workflow superstep, Foundry Hosting:
|
||||
|
||||
1. Closes the response output item produced by that superstep.
|
||||
2. Saves the matching AgentSession.
|
||||
3. Writes the workflow checkpoint ID to AgentServer internal response metadata as
|
||||
`_last_checkpoint_id`.
|
||||
4. Emits the updated `response.in_progress` state so AgentServer's authoritative response includes
|
||||
the internal metadata.
|
||||
5. Yields `ResponseEventStream.Checkpoint()`.
|
||||
|
||||
On recovery, the handler reads `_last_checkpoint_id` from `PersistedResponse` and selects that exact
|
||||
workflow checkpoint before execution continues.
|
||||
|
||||
## Local development
|
||||
|
||||
The easiest local demonstration is the automated E2E console:
|
||||
|
||||
```powershell
|
||||
dotnet run --project dotnet\samples\04-hosting\FoundryHostedAgents\responses\Using-E2E-Resilience
|
||||
```
|
||||
|
||||
It starts this server, prints countdown outputs, force-kills the process, restarts it, prints replay
|
||||
and recovery outputs, and validates the final sequence.
|
||||
|
||||
To run only the server, copy `.env.example` to `.env`, then run:
|
||||
|
||||
```powershell
|
||||
dotnet run --tl:off
|
||||
```
|
||||
|
||||
Set `COUNTDOWN_DELAY_SECONDS=0` to make a normal run complete immediately.
|
||||
|
||||
## Deploy from source
|
||||
|
||||
Create an empty working directory outside the repository:
|
||||
|
||||
```powershell
|
||||
$work = Join-Path $env:TEMP "hosted-workflow-resilient-long-running-work"
|
||||
New-Item -ItemType Directory -Path $work -Force | Out-Null
|
||||
Set-Location $work
|
||||
|
||||
$sample = "<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/azure.yaml"
|
||||
azd auth login
|
||||
azd ai agent init -m $sample
|
||||
```
|
||||
|
||||
### Contributors testing framework changes
|
||||
|
||||
Skip this section unless the current framework changes have not been released. Pack the repository
|
||||
source into the scaffolded upload before provisioning:
|
||||
|
||||
```powershell
|
||||
<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/Add-LocalFrameworkFeed.ps1 `
|
||||
-Path ./hosted-workflow-resilient-long-running
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```powershell
|
||||
Set-Location hosted-workflow-resilient-long-running
|
||||
azd provision
|
||||
azd deploy
|
||||
```
|
||||
|
||||
Grant the hosted agent identity `Foundry User` on the Foundry project so it can write workflow
|
||||
checkpoints and AgentSession state.
|
||||
|
||||
## Automated coverage
|
||||
|
||||
`ResilientTwoLifetimeIntegrationTests.StoppedHost_RecoversWorkflowWithCompleteOrderedOutputAsync`
|
||||
starts the Responses host twice over shared durable state. It interrupts the first host while the
|
||||
counter is processing `3`, then verifies that the recovered response contains exactly:
|
||||
|
||||
```text
|
||||
6, 5, 4, 3, 2, 1, Countdown complete.
|
||||
```
|
||||
|
||||
## Related samples
|
||||
|
||||
- [Using-E2E-Resilience](../Using-E2E-Resilience/README.md): automated local crash-recovery console.
|
||||
- [Hosted-Workflow-Resilient](../Hosted-Workflow-Resilient/README.md): resilient model-backed translation workflow.
|
||||
- [Hosted-Workflow-Simple](../Hosted-Workflow-Simple/README.md): workflow hosting without resilient background execution.
|
||||
- [Hosted-Steering](../Hosted-Steering/README.md): mid-turn steering.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
|
||||
|
||||
name: hosted-workflow-resilient-long-running
|
||||
services:
|
||||
ai-project:
|
||||
host: azure.ai.project
|
||||
hosted-workflow-resilient-long-running:
|
||||
project: .
|
||||
host: azure.ai.agent
|
||||
language: csharp
|
||||
uses:
|
||||
- ai-project
|
||||
codeConfiguration:
|
||||
dependencyResolution: remote_build
|
||||
entryPoint: HostedWorkflowResilientLongRunning.dll
|
||||
runtime: dotnet_10
|
||||
env:
|
||||
ASPNETCORE_URLS: http://+:8088
|
||||
COUNTDOWN_DELAY_SECONDS: "1"
|
||||
container:
|
||||
resources:
|
||||
cpu: "0.5"
|
||||
memory: 1Gi
|
||||
description: |
|
||||
A resilient long-running countdown workflow hosted with the Foundry Responses protocol.
|
||||
kind: hosted
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Agent Framework
|
||||
- Resilient Background
|
||||
- Long Running
|
||||
name: hosted-workflow-resilient-long-running
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 2.0.0
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# azd tooling files
|
||||
azure.yaml
|
||||
.agentignore
|
||||
|
||||
# Security / secrets
|
||||
.env
|
||||
.env.*
|
||||
.azure/
|
||||
.git/
|
||||
|
||||
# .NET build output
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
|
||||
# Local agent state
|
||||
.checkpoints/
|
||||
.agentserver-state/
|
||||
.agentserver-state-*/
|
||||
.home-*/
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
# Foundry project endpoint
|
||||
FOUNDRY_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
|
||||
|
||||
# Model deployment name
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
|
||||
# Local development only
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
AZURE_TOKEN_CREDENTIALS=dev
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<ImportDirectoryPackagesProps>false</ImportDirectoryPackagesProps>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk.Web" />
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TargetFrameworks></TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>HostedWorkflowResilient</RootNamespace>
|
||||
<AssemblyName>HostedWorkflowResilient</AssemblyName>
|
||||
<UserSecretsId>b45eca04-a1ba-4c64-8318-a6051e83b485</UserSecretsId>
|
||||
<AgentFrameworkVersion>1.18.0-preview.260818.1</AgentFrameworkVersion>
|
||||
<LocalAgentFrameworkRoot>$(MSBuildThisFileDirectory)..\..\..\..\..\src</LocalAgentFrameworkRoot>
|
||||
<UseLocalAgentFramework Condition="'$(UseLocalAgentFramework)' == '' and Exists('$(LocalAgentFrameworkRoot)\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj')">true</UseLocalAgentFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="'$(UseLocalAgentFramework)' == 'true'">
|
||||
<ProjectReference Include="$(LocalAgentFrameworkRoot)\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="$(LocalAgentFrameworkRoot)\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(UseLocalAgentFramework)' != 'true'">
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="$(AgentFrameworkVersion)" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="$(AgentFrameworkVersion)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" Version="2.1.0-beta.4" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.21.0" />
|
||||
<PackageReference Include="DotNetEnv" Version="3.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk.Web" />
|
||||
|
||||
</Project>
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Sample: a resilient background workflow hosted with the Foundry Responses protocol. AgentServer
|
||||
// re-invokes an interrupted response, while the workflow resumes from its durable checkpoint.
|
||||
// It deploys directly from source, so Foundry builds and runs the uploaded project.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
Env.TraversePath().Load();
|
||||
|
||||
var projectEndpoint = new Uri(System.Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
|
||||
var deployment = FirstNonBlank(
|
||||
System.Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME"),
|
||||
System.Environment.GetEnvironmentVariable("FOUNDRY_MODEL"),
|
||||
"gpt-4o");
|
||||
var agentName = System.Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-workflow-resilient";
|
||||
|
||||
IChatClient chatClient = new AIProjectClient(projectEndpoint, new DefaultAzureCredential())
|
||||
.GetProjectOpenAIClient()
|
||||
.GetChatClient(deployment)
|
||||
.AsIChatClient();
|
||||
|
||||
AIAgent frenchAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Id = "french-translator",
|
||||
Name = "French Translator",
|
||||
ChatOptions = new() { Instructions = "Translate the provided text to French. Return only the translation." },
|
||||
});
|
||||
AIAgent spanishAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Id = "spanish-translator",
|
||||
Name = "Spanish Translator",
|
||||
ChatOptions = new() { Instructions = "Translate the provided text to Spanish. Return only the translation." },
|
||||
});
|
||||
AIAgent englishAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Id = "english-translator",
|
||||
Name = "English Translator",
|
||||
ChatOptions = new() { Instructions = "Translate the provided text to English. Return only the translation." },
|
||||
});
|
||||
|
||||
AIAgent agent = new WorkflowBuilder(frenchAgent)
|
||||
.AddEdge(frenchAgent, spanishAgent)
|
||||
.AddEdge(spanishAgent, englishAgent)
|
||||
.Build()
|
||||
.AsAIAgent(name: agentName);
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent, configure: options => options.ResilientBackground = true);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
app.Run();
|
||||
|
||||
static string FirstNonBlank(params string?[] candidates) =>
|
||||
Array.Find(candidates, candidate => !string.IsNullOrWhiteSpace(candidate))!;
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
# Hosted-Workflow-Resilient
|
||||
|
||||
A sequential translation workflow hosted with resilient background Responses enabled. AgentServer
|
||||
re-invokes an interrupted background response, Foundry Hosting reloads the AgentSession, and the
|
||||
workflow runtime continues from the checkpoint referenced by that session.
|
||||
|
||||
This sample deploys directly from source. Foundry uploads the project as a ZIP, restores its
|
||||
packages, builds it, and runs `HostedWorkflowResilient.dll`. No Dockerfile or container registry is
|
||||
needed.
|
||||
|
||||
## Key setting
|
||||
|
||||
```csharp
|
||||
builder.Services.AddFoundryResponses(
|
||||
agent,
|
||||
configure: options => options.ResilientBackground = true);
|
||||
```
|
||||
|
||||
Each workflow agent has a fixed `Id` and `Name`. A restarted process must reconstruct the same
|
||||
executor identities for a stored workflow checkpoint to match.
|
||||
|
||||
## State ownership
|
||||
|
||||
| State | Owner |
|
||||
| --- | --- |
|
||||
| Background task, response events, and selected response snapshots | AgentServer |
|
||||
| AgentSession and workflow checkpoint reference | `FoundryAgentSessionStore` |
|
||||
| Workflow execution checkpoints | `FoundryJsonCheckpointStore` |
|
||||
|
||||
At each completed workflow superstep, the hosting adapter saves the AgentSession, records the
|
||||
workflow checkpoint ID in AgentServer internal response metadata, and calls
|
||||
`ResponseEventStream.Checkpoint()`. Recovery selects that exact workflow checkpoint ID. The response
|
||||
output count is not used as the workflow cursor.
|
||||
|
||||
## Local development
|
||||
|
||||
Copy `.env.example` to `.env`, set the project endpoint and model deployment, then run:
|
||||
|
||||
```powershell
|
||||
az login
|
||||
dotnet run --tl:off
|
||||
```
|
||||
|
||||
The in-repository project automatically uses ProjectReference to run the current framework source.
|
||||
|
||||
## Deploy from source
|
||||
|
||||
Create an empty working directory outside the repository:
|
||||
|
||||
```powershell
|
||||
$work = Join-Path $env:TEMP "hosted-workflow-resilient-work"
|
||||
New-Item -ItemType Directory -Path $work -Force | Out-Null
|
||||
Set-Location $work
|
||||
|
||||
$sample = "<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient/azure.yaml"
|
||||
azd auth login
|
||||
azd ai agent init -m $sample -d <model-deployment>
|
||||
```
|
||||
|
||||
### Contributors testing framework changes
|
||||
|
||||
**Skip this section unless you are testing an Agent Framework change from the current codebase that
|
||||
has not been released yet.** The normal deployment uses the published packages. To test local
|
||||
framework changes, pack the current repository source into the scaffolded upload before provisioning:
|
||||
|
||||
```powershell
|
||||
<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/Add-LocalFrameworkFeed.ps1 `
|
||||
-Path ./hosted-workflow-resilient
|
||||
```
|
||||
|
||||
The helper creates `local-feed/`, writes `nuget.config`, and changes `AgentFrameworkVersion` in the
|
||||
scaffolded project. Both generated artifacts are included in the source ZIP.
|
||||
|
||||
```powershell
|
||||
Set-Location hosted-workflow-resilient
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME <model-deployment>
|
||||
azd provision
|
||||
azd deploy
|
||||
```
|
||||
|
||||
The workflow checkpoint store writes through the hosted agent's managed identity. Grant that
|
||||
identity `Foundry User` on the existing Foundry project after the first deployment:
|
||||
|
||||
```powershell
|
||||
$agent = azd ai agent show hosted-workflow-resilient -o json | ConvertFrom-Json
|
||||
az role assignment create `
|
||||
--assignee-object-id $agent.instance_identity.principal_id `
|
||||
--assignee-principal-type ServicePrincipal `
|
||||
--role "Foundry User" `
|
||||
--scope <foundry-project-resource-id>
|
||||
```
|
||||
|
||||
Allow a few minutes for the role assignment to take effect before the first request.
|
||||
|
||||
Submit the request with `store=true` and `background=true`. Poll the returned response id until it
|
||||
reaches a terminal status.
|
||||
|
||||
## Live integration coverage
|
||||
|
||||
`Foundry.Hosting.IntegrationTests` contains a deterministic `resilient-workflow` scenario:
|
||||
|
||||
- `long:<token>` holds a background workflow without client traffic, then completes with the token.
|
||||
- `crash:<token>` writes a crash-once marker, terminates the container process, and completes only
|
||||
after AgentServer reclaims the response and the workflow resumes in a replacement process.
|
||||
|
||||
The test suite deploys that scenario to a real Foundry project and validates both behaviors.
|
||||
|
||||
## Related samples
|
||||
|
||||
- [Hosted-Workflow-Resilient-Long-Running](../Hosted-Workflow-Resilient-Long-Running/README.md): deterministic countdown recovery with exact output validation.
|
||||
- [Hosted-Workflow-Simple](../Hosted-Workflow-Simple/README.md): workflow hosting without resilient background execution.
|
||||
- [Hosted-Steering](../Hosted-Steering/README.md): mid-turn steering.
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
|
||||
|
||||
name: hosted-workflow-resilient
|
||||
services:
|
||||
ai-project:
|
||||
host: azure.ai.project
|
||||
hosted-workflow-resilient:
|
||||
project: .
|
||||
host: azure.ai.agent
|
||||
language: csharp
|
||||
uses:
|
||||
- ai-project
|
||||
codeConfiguration:
|
||||
dependencyResolution: remote_build
|
||||
entryPoint: HostedWorkflowResilient.dll
|
||||
runtime: dotnet_10
|
||||
env:
|
||||
ASPNETCORE_URLS: http://+:8088
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
|
||||
container:
|
||||
resources:
|
||||
cpu: "0.5"
|
||||
memory: 1Gi
|
||||
description: |
|
||||
A resilient background translation workflow hosted with the Foundry Responses protocol.
|
||||
kind: hosted
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Agent Framework
|
||||
- Resilient Background
|
||||
name: hosted-workflow-resilient
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 2.0.0
|
||||
+7
-1
@@ -205,4 +205,10 @@ that conversation no longer exists on the server. Start a fresh one:
|
||||
azd ai agent invoke --new-conversation "Hello!"
|
||||
```
|
||||
|
||||
For the full hosted-agent deployment guide, see the [official source-code deployment doc](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent-code).
|
||||
For the full hosted-agent deployment guide, see the [official source-code deployment doc](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent-code).
|
||||
|
||||
## Related samples
|
||||
|
||||
- [Hosted-Workflow-Resilient](../Hosted-Workflow-Resilient/README.md): adds resilient background execution to a model-backed workflow.
|
||||
- [Hosted-Workflow-Resilient-Long-Running](../Hosted-Workflow-Resilient-Long-Running/README.md): deterministic countdown recovery with exact output validation.
|
||||
- [Hosted-Workflow-Handoff](../Hosted-Workflow-Handoff/README.md): routes work between multiple specialized agents.
|
||||
+1
@@ -12,6 +12,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Core" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.Core;
|
||||
|
||||
namespace Hosted_Shared_Contributor_Setup;
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites an HTTPS request to a loopback HTTP endpoint immediately before transport.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Local development clients present an HTTPS endpoint to the bearer-token pipeline so it can
|
||||
/// attach a token, then use this handler to reach a loopback HTTP server.
|
||||
/// </remarks>
|
||||
public sealed class LocalHttpSchemeRewriteHandler : DelegatingHandler
|
||||
{
|
||||
private readonly Uri _localEndpoint;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance that routes requests to <paramref name="localEndpoint"/>.
|
||||
/// </summary>
|
||||
/// <param name="localEndpoint">The loopback HTTP endpoint hosting the local agent.</param>
|
||||
public LocalHttpSchemeRewriteHandler(Uri localEndpoint)
|
||||
: base(new HttpClientHandler())
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(localEndpoint);
|
||||
if (!localEndpoint.IsLoopback
|
||||
|| localEndpoint.Scheme != Uri.UriSchemeHttp)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The local endpoint must be an HTTP loopback URI.",
|
||||
nameof(localEndpoint));
|
||||
}
|
||||
|
||||
this._localEndpoint = localEndpoint;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
this.RewriteUri(request);
|
||||
return base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
private void RewriteUri(HttpRequestMessage request)
|
||||
{
|
||||
Uri uri = request.RequestUri
|
||||
?? throw new InvalidOperationException("The local request URI is missing.");
|
||||
if (!uri.IsLoopback)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The local HTTP rewrite policy can only target a loopback endpoint.");
|
||||
}
|
||||
|
||||
if (uri.Scheme == Uri.UriSchemeHttps)
|
||||
{
|
||||
request.RequestUri =
|
||||
new UriBuilder(uri)
|
||||
{
|
||||
Scheme = Uri.UriSchemeHttp,
|
||||
Host = this._localEndpoint.Host,
|
||||
Port = this._localEndpoint.Port,
|
||||
}.Uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supplies a placeholder bearer token for a loopback server that does not validate authentication.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This credential is only for local sample development. It must not be used with remote services.
|
||||
/// </remarks>
|
||||
public sealed class LocalDevelopmentTokenCredential : TokenCredential
|
||||
{
|
||||
private static readonly AccessToken s_token =
|
||||
new("local-development", DateTimeOffset.MaxValue);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AccessToken GetToken(
|
||||
TokenRequestContext requestContext,
|
||||
CancellationToken cancellationToken) =>
|
||||
s_token;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<AccessToken> GetTokenAsync(
|
||||
TokenRequestContext requestContext,
|
||||
CancellationToken cancellationToken) =>
|
||||
new(s_token);
|
||||
}
|
||||
+971
@@ -0,0 +1,971 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Azure.AI.Projects;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
const string AgentName = "hosted-workflow-resilient-long-running";
|
||||
VerificationOptions options = VerificationOptions.Parse(args);
|
||||
string repositoryRoot = FindRepositoryRoot();
|
||||
string serverProject = Path.Combine(
|
||||
repositoryRoot,
|
||||
"dotnet",
|
||||
"samples",
|
||||
"04-hosting",
|
||||
"FoundryHostedAgents",
|
||||
"responses",
|
||||
"Hosted-Workflow-Resilient-Long-Running",
|
||||
"HostedWorkflowResilientLongRunning.csproj");
|
||||
string workingRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"maf-resilient-workflow-{Guid.NewGuid():N}");
|
||||
string serverOutput = Path.Combine(workingRoot, "server");
|
||||
string serverAssembly = Path.Combine(
|
||||
serverOutput,
|
||||
"HostedWorkflowResilientLongRunning.dll");
|
||||
string stateRoot = Path.Combine(workingRoot, "state");
|
||||
string logPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"maf-resilient-workflow-{Guid.NewGuid():N}.log");
|
||||
int port = GetAvailablePort();
|
||||
var baseAddress = new Uri($"http://127.0.0.1:{port}");
|
||||
bool succeeded = false;
|
||||
|
||||
Directory.CreateDirectory(workingRoot);
|
||||
|
||||
var cancellationSource = new CancellationTokenSource(TimeSpan.FromMinutes(3));
|
||||
var client = new HttpClient
|
||||
{
|
||||
BaseAddress = baseAddress,
|
||||
Timeout = Timeout.InfiniteTimeSpan,
|
||||
};
|
||||
await using var logWriter = new StreamWriter(logPath, append: false, new UTF8Encoding(false))
|
||||
{
|
||||
AutoFlush = true,
|
||||
};
|
||||
ServerProcess? server = null;
|
||||
Task? createStream = null;
|
||||
AgentStreamObserver? streamObserver = null;
|
||||
LocalAgentClient? localAgentClient = null;
|
||||
CancellationTokenSource? initialStreamCancellation = null;
|
||||
try
|
||||
{
|
||||
PrintHeader(options, stateRoot, logPath);
|
||||
|
||||
Console.WriteLine("Preparing isolated Debug server binaries...");
|
||||
await BuildServerAsync(
|
||||
serverProject,
|
||||
serverOutput,
|
||||
logWriter,
|
||||
cancellationSource.Token);
|
||||
Console.WriteLine(" server build complete");
|
||||
Console.WriteLine();
|
||||
|
||||
Console.WriteLine("[1/7] Starting the first server process...");
|
||||
Console.WriteLine($" endpoint: {baseAddress}");
|
||||
server = StartServer(
|
||||
serverAssembly,
|
||||
stateRoot,
|
||||
port,
|
||||
options.DelaySeconds,
|
||||
logWriter);
|
||||
Console.WriteLine($" process tree root: {server.Id}");
|
||||
await WaitForReadinessAsync(client, cancellationSource.Token);
|
||||
Console.WriteLine(" server ready");
|
||||
Console.WriteLine();
|
||||
|
||||
localAgentClient = CreateClientAgent(baseAddress, AgentName);
|
||||
AIAgent agent = localAgentClient.Agent;
|
||||
AgentSession session = await agent.CreateSessionAsync(cancellationSource.Token);
|
||||
AgentRunOptions runOptions = new() { AllowBackgroundResponses = true };
|
||||
|
||||
Console.WriteLine("[2/7] Starting the background countdown...");
|
||||
streamObserver = new AgentStreamObserver(options.CrashAfterCount);
|
||||
initialStreamCancellation =
|
||||
CancellationTokenSource.CreateLinkedTokenSource(cancellationSource.Token);
|
||||
#pragma warning disable CA2025 // The stream must run concurrently until the server is killed; finally awaits it before disposing resources.
|
||||
createStream = WatchInitialAgentStreamAsync(
|
||||
agent,
|
||||
session,
|
||||
runOptions,
|
||||
options.Target,
|
||||
streamObserver,
|
||||
initialStreamCancellation.Token);
|
||||
#pragma warning restore CA2025
|
||||
|
||||
string responseId = await WaitForResponseIdAsync(
|
||||
streamObserver,
|
||||
createStream,
|
||||
cancellationSource.Token);
|
||||
Console.WriteLine($" response id: {responseId}");
|
||||
Console.WriteLine();
|
||||
|
||||
Console.WriteLine(
|
||||
$"[3/7] Waiting for {options.CrashAfterCount} countdown items and their response checkpoint...");
|
||||
await streamObserver.CrashPointReached.Task.WaitAsync(cancellationSource.Token);
|
||||
await WaitForPersistedResponseCheckpointAsync(
|
||||
stateRoot,
|
||||
responseId,
|
||||
streamObserver.CompletedTexts,
|
||||
cancellationSource.Token);
|
||||
Console.WriteLine(" checkpoint persisted");
|
||||
Console.WriteLine();
|
||||
|
||||
Console.WriteLine("[4/7] Force-killing the first server process...");
|
||||
initialStreamCancellation.Cancel();
|
||||
await IgnoreExpectedDisconnectAsync(createStream);
|
||||
createStream = null;
|
||||
initialStreamCancellation.Dispose();
|
||||
initialStreamCancellation = null;
|
||||
await server.KillAsync();
|
||||
server = null;
|
||||
DeleteStaleStreamLocks(stateRoot);
|
||||
Console.WriteLine(" process terminated");
|
||||
Console.WriteLine();
|
||||
|
||||
Console.WriteLine("[5/7] Starting a replacement server over the same durable state...");
|
||||
server = StartServer(
|
||||
serverAssembly,
|
||||
stateRoot,
|
||||
port,
|
||||
options.DelaySeconds,
|
||||
logWriter);
|
||||
Console.WriteLine($" process tree root: {server.Id}");
|
||||
await WaitForReadinessAsync(client, cancellationSource.Token);
|
||||
Console.WriteLine(" recovery scan completed");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("[6/7] Reconnecting with the sequence-aware continuation token...");
|
||||
streamObserver.BeginRecovery();
|
||||
runOptions.ContinuationToken = streamObserver.ContinuationToken
|
||||
?? throw new InvalidOperationException(
|
||||
"The initial stream did not provide a continuation token.");
|
||||
await WatchRecoveredAgentStreamAsync(
|
||||
agent,
|
||||
session,
|
||||
runOptions,
|
||||
streamObserver,
|
||||
cancellationSource.Token);
|
||||
|
||||
List<string> actual = streamObserver.CompletedTexts;
|
||||
List<string> expected =
|
||||
[
|
||||
.. Enumerable.Range(1, options.Target)
|
||||
.Reverse()
|
||||
.Select(value => value.ToString(CultureInfo.InvariantCulture)),
|
||||
"Countdown complete.",
|
||||
];
|
||||
|
||||
if (!streamObserver.ResponseCompleted)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The recovered stream ended without response.completed.");
|
||||
}
|
||||
|
||||
if (!actual.SequenceEqual(expected))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Recovered output did not match the expected countdown." +
|
||||
$"{Environment.NewLine}Expected: {string.Join(", ", expected)}" +
|
||||
$"{Environment.NewLine}Actual: {string.Join(", ", actual)}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("[7/7] Replaying from the start without a sequence cursor...");
|
||||
AgentRunOptions replayOptions = new()
|
||||
{
|
||||
AllowBackgroundResponses = true,
|
||||
ContinuationToken = CreateReplayFromStartToken(responseId),
|
||||
};
|
||||
var replayObserver = new AgentStreamObserver(int.MaxValue);
|
||||
await WatchReplayedAgentStreamAsync(
|
||||
agent,
|
||||
session,
|
||||
replayOptions,
|
||||
replayObserver,
|
||||
cancellationSource.Token);
|
||||
if (!replayObserver.ResponseCompleted
|
||||
|| !replayObserver.CompletedTexts.SequenceEqual(expected))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The cursor-free replay did not return the complete countdown.");
|
||||
}
|
||||
int retainedCountdownUpdates =
|
||||
actual.Count(text => text != "Countdown complete.");
|
||||
int replayedCountdownUpdates =
|
||||
replayObserver.CompletedTexts.Count(
|
||||
text => text != "Countdown complete.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(
|
||||
$"Client retained countdown updates: {retainedCountdownUpdates}");
|
||||
Console.WriteLine(
|
||||
$"Replay countdown updates: {replayedCountdownUpdates}");
|
||||
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine(
|
||||
"PASS: crash recovery completed with ordered output and no missing or duplicated items.");
|
||||
Console.ResetColor();
|
||||
succeeded = true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.Error.WriteLine($"FAIL: {exception.Message}");
|
||||
Console.ResetColor();
|
||||
Console.Error.WriteLine($"Server log: {logPath}");
|
||||
System.Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (server is not null)
|
||||
{
|
||||
await server.KillAsync();
|
||||
}
|
||||
|
||||
if (createStream is not null)
|
||||
{
|
||||
initialStreamCancellation?.Cancel();
|
||||
await IgnoreExpectedDisconnectAsync(createStream);
|
||||
}
|
||||
|
||||
initialStreamCancellation?.Dispose();
|
||||
client.Dispose();
|
||||
localAgentClient?.Dispose();
|
||||
cancellationSource.Dispose();
|
||||
|
||||
if (succeeded)
|
||||
{
|
||||
TryDeleteDirectory(workingRoot);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine($"E2E working directory retained at: {workingRoot}");
|
||||
}
|
||||
}
|
||||
|
||||
static void PrintHeader(
|
||||
VerificationOptions options,
|
||||
string stateRoot,
|
||||
string logPath)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine("============================================================");
|
||||
Console.WriteLine("Resilient long-running workflow E2E demonstration");
|
||||
Console.WriteLine("============================================================");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine($"Countdown target: {options.Target}");
|
||||
Console.WriteLine($"Crash after: {options.CrashAfterCount} message items");
|
||||
Console.WriteLine($"Step delay: {options.DelaySeconds} second(s)");
|
||||
Console.WriteLine($"Durable state: {stateRoot}");
|
||||
Console.WriteLine($"Server log: {logPath}");
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
static ServerProcess StartServer(
|
||||
string serverAssembly,
|
||||
string stateRoot,
|
||||
int port,
|
||||
int delaySeconds,
|
||||
TextWriter logWriter)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "dotnet",
|
||||
WorkingDirectory = Path.GetDirectoryName(serverAssembly)!,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
startInfo.ArgumentList.Add("exec");
|
||||
startInfo.ArgumentList.Add(serverAssembly);
|
||||
startInfo.Environment["AGENTSERVER_STATE_ROOT"] = stateRoot;
|
||||
startInfo.Environment["FOUNDRY_AGENT_SESSION_ID"] = "using-e2e-resilience";
|
||||
startInfo.Environment["AGENT_NAME"] = AgentName;
|
||||
startInfo.Environment["ASPNETCORE_URLS"] = $"http://127.0.0.1:{port}";
|
||||
startInfo.Environment["ASPNETCORE_ENVIRONMENT"] = "Development";
|
||||
startInfo.Environment["COUNTDOWN_DELAY_SECONDS"] =
|
||||
delaySeconds.ToString(CultureInfo.InvariantCulture);
|
||||
startInfo.Environment["DOTNET_NOLOGO"] = "true";
|
||||
startInfo.Environment.Remove("FOUNDRY_HOSTING_ENVIRONMENT");
|
||||
|
||||
return ServerProcess.Start(startInfo, logWriter);
|
||||
}
|
||||
|
||||
static LocalAgentClient CreateClientAgent(Uri baseAddress, string agentName)
|
||||
{
|
||||
Uri httpsProjectEndpoint = new UriBuilder(baseAddress)
|
||||
{
|
||||
Scheme = Uri.UriSchemeHttps,
|
||||
Port = baseAddress.Port,
|
||||
}.Uri;
|
||||
|
||||
var transportClient = new HttpClient(
|
||||
new LocalHttpSchemeRewriteHandler(baseAddress));
|
||||
var clientOptions = new AIProjectClientOptions
|
||||
{
|
||||
Transport = new HttpClientPipelineTransport(transportClient),
|
||||
};
|
||||
|
||||
AIAgent agent = new AIProjectClient(
|
||||
httpsProjectEndpoint,
|
||||
new LocalDevelopmentTokenCredential(),
|
||||
clientOptions)
|
||||
.AsAIAgent(
|
||||
model: agentName,
|
||||
instructions: "Invoke the local hosted countdown workflow.");
|
||||
return new LocalAgentClient(agent, transportClient);
|
||||
}
|
||||
|
||||
static ResponseContinuationToken CreateReplayFromStartToken(
|
||||
string responseId)
|
||||
{
|
||||
ResponseContinuationToken innerToken =
|
||||
ResponseContinuationToken.FromBytes(
|
||||
JsonSerializer.SerializeToUtf8Bytes(
|
||||
new { responseId }));
|
||||
string serializedInnerToken = JsonSerializer.Serialize(
|
||||
innerToken,
|
||||
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(
|
||||
typeof(ResponseContinuationToken)));
|
||||
byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(
|
||||
new
|
||||
{
|
||||
type = "chatClientAgentContinuationToken",
|
||||
innerToken = serializedInnerToken,
|
||||
});
|
||||
return ResponseContinuationToken.FromBytes(bytes);
|
||||
}
|
||||
|
||||
static async Task BuildServerAsync(
|
||||
string serverProject,
|
||||
string serverOutput,
|
||||
TextWriter logWriter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "dotnet",
|
||||
WorkingDirectory = Path.GetDirectoryName(serverProject)!,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
startInfo.ArgumentList.Add("build");
|
||||
startInfo.ArgumentList.Add(serverProject);
|
||||
startInfo.ArgumentList.Add("--configuration");
|
||||
startInfo.ArgumentList.Add("Debug");
|
||||
startInfo.ArgumentList.Add("--output");
|
||||
startInfo.ArgumentList.Add(serverOutput);
|
||||
startInfo.ArgumentList.Add("--tl:off");
|
||||
startInfo.Environment["DOTNET_NOLOGO"] = "true";
|
||||
|
||||
using Process process = Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException("Could not start the server build.");
|
||||
TextWriter synchronizedLogWriter = TextWriter.Synchronized(logWriter);
|
||||
process.OutputDataReceived += (_, eventArgs) =>
|
||||
{
|
||||
if (eventArgs.Data is not null)
|
||||
{
|
||||
synchronizedLogWriter.WriteLine($"[build stdout] {eventArgs.Data}");
|
||||
}
|
||||
};
|
||||
process.ErrorDataReceived += (_, eventArgs) =>
|
||||
{
|
||||
if (eventArgs.Data is not null)
|
||||
{
|
||||
synchronizedLogWriter.WriteLine($"[build stderr] {eventArgs.Data}");
|
||||
}
|
||||
};
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
process.WaitForExit();
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Server build failed with exit code {process.ExitCode}.");
|
||||
}
|
||||
}
|
||||
|
||||
static async Task WaitForReadinessAsync(
|
||||
HttpClient client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(30);
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var requestCancellation =
|
||||
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
requestCancellation.CancelAfter(TimeSpan.FromSeconds(2));
|
||||
using HttpResponseMessage response = await client.GetAsync(
|
||||
new Uri("readiness", UriKind.Relative),
|
||||
requestCancellation.Token);
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
when (exception is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken);
|
||||
}
|
||||
|
||||
throw new TimeoutException("Server did not become ready within 30 seconds.");
|
||||
}
|
||||
|
||||
static async Task WatchInitialAgentStreamAsync(
|
||||
AIAgent agent,
|
||||
AgentSession session,
|
||||
AgentRunOptions options,
|
||||
int target,
|
||||
AgentStreamObserver observer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(
|
||||
$"Count down from {target}",
|
||||
session,
|
||||
options,
|
||||
cancellationToken))
|
||||
{
|
||||
observer.ObserveInitial(update);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task WatchRecoveredAgentStreamAsync(
|
||||
AIAgent agent,
|
||||
AgentSession session,
|
||||
AgentRunOptions options,
|
||||
AgentStreamObserver observer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(
|
||||
session,
|
||||
options,
|
||||
cancellationToken))
|
||||
{
|
||||
observer.ObserveRecovered(update);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task WatchReplayedAgentStreamAsync(
|
||||
AIAgent agent,
|
||||
AgentSession session,
|
||||
AgentRunOptions options,
|
||||
AgentStreamObserver observer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(
|
||||
session,
|
||||
options,
|
||||
cancellationToken))
|
||||
{
|
||||
observer.ObserveReplayed(update);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<string> WaitForResponseIdAsync(
|
||||
AgentStreamObserver observer,
|
||||
Task createStream,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Task completed = await Task.WhenAny(
|
||||
observer.ResponseId.Task,
|
||||
createStream,
|
||||
Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken));
|
||||
if (completed == createStream)
|
||||
{
|
||||
await createStream;
|
||||
throw new InvalidOperationException(
|
||||
"The initial stream ended before returning a response ID.");
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return await observer.ResponseId.Task;
|
||||
}
|
||||
|
||||
static async Task WaitForPersistedResponseCheckpointAsync(
|
||||
string stateRoot,
|
||||
string responseId,
|
||||
IReadOnlyList<string> expectedPrefix,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string path = Path.Combine(
|
||||
stateRoot,
|
||||
"responses",
|
||||
"envelopes",
|
||||
$"{responseId}.json");
|
||||
var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(15);
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
using FileStream file = new(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.ReadWrite | FileShare.Delete);
|
||||
using JsonDocument document = await JsonDocument.ParseAsync(
|
||||
file,
|
||||
cancellationToken: cancellationToken);
|
||||
JsonElement response =
|
||||
document.RootElement.GetProperty("envelope");
|
||||
List<string> persistedTexts = GetPersistedMessageTexts(response);
|
||||
bool hasCheckpointMetadata =
|
||||
response.TryGetProperty("metadata", out JsonElement metadata)
|
||||
&& metadata.TryGetProperty("_internal_metadata", out JsonElement internalMetadata)
|
||||
&& !string.IsNullOrWhiteSpace(internalMetadata.GetString());
|
||||
if (hasCheckpointMetadata
|
||||
&& persistedTexts.Count >= expectedPrefix.Count
|
||||
&& persistedTexts
|
||||
.Take(expectedPrefix.Count)
|
||||
.SequenceEqual(expectedPrefix))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
when (exception is IOException
|
||||
or JsonException
|
||||
or KeyNotFoundException)
|
||||
{
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
|
||||
}
|
||||
|
||||
throw new TimeoutException(
|
||||
"The response checkpoint was not persisted within 15 seconds.");
|
||||
}
|
||||
|
||||
static List<string> GetPersistedMessageTexts(JsonElement response)
|
||||
{
|
||||
List<string> texts = [];
|
||||
foreach (JsonElement item in response.GetProperty("output").EnumerateArray())
|
||||
{
|
||||
if (item.GetProperty("type").GetString() != "message")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (JsonElement content in item.GetProperty("content").EnumerateArray())
|
||||
{
|
||||
if (content.GetProperty("type").GetString() == "output_text")
|
||||
{
|
||||
texts.Add(content.GetProperty("text").GetString() ?? string.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return texts;
|
||||
}
|
||||
|
||||
static async Task IgnoreExpectedDisconnectAsync(Task streamTask)
|
||||
{
|
||||
try
|
||||
{
|
||||
await streamTask;
|
||||
}
|
||||
catch (Exception exception)
|
||||
when (IsExpectedDisconnect(exception))
|
||||
{
|
||||
}
|
||||
|
||||
static bool IsExpectedDisconnect(Exception exception)
|
||||
{
|
||||
if (exception is AggregateException aggregate)
|
||||
{
|
||||
return aggregate
|
||||
.Flatten()
|
||||
.InnerExceptions
|
||||
.All(IsExpectedDisconnect);
|
||||
}
|
||||
|
||||
return exception is ClientResultException
|
||||
or HttpRequestException
|
||||
or IOException
|
||||
or OperationCanceledException;
|
||||
}
|
||||
}
|
||||
|
||||
static void DeleteStaleStreamLocks(string stateRoot)
|
||||
{
|
||||
string streamsPath = Path.Combine(stateRoot, "streams");
|
||||
if (!Directory.Exists(streamsPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (string lockPath in Directory.EnumerateFiles(
|
||||
streamsPath,
|
||||
"*.jsonl.lock",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
for (int attempt = 1; attempt <= 10; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(lockPath);
|
||||
break;
|
||||
}
|
||||
catch (UnauthorizedAccessException) when (attempt < 10)
|
||||
{
|
||||
Thread.Sleep(TimeSpan.FromMilliseconds(250));
|
||||
}
|
||||
catch (IOException) when (attempt < 10)
|
||||
{
|
||||
Thread.Sleep(TimeSpan.FromMilliseconds(250));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static int GetAvailablePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
int port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
static string FindRepositoryRoot()
|
||||
{
|
||||
foreach (string start in new[] { Environment.CurrentDirectory, AppContext.BaseDirectory })
|
||||
{
|
||||
DirectoryInfo? directory = new(start);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(
|
||||
directory.FullName,
|
||||
"dotnet",
|
||||
"agent-framework-dotnet.slnx")))
|
||||
{
|
||||
return directory.FullName;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Could not find the Agent Framework repository root.");
|
||||
}
|
||||
|
||||
static void TryDeleteDirectory(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(path, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AgentStreamObserver(int crashAfterCount)
|
||||
{
|
||||
private readonly Dictionary<string, StringBuilder> _messageBuffers =
|
||||
new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _completedMessageIds =
|
||||
new(StringComparer.Ordinal);
|
||||
private List<string>? _preCrashTexts;
|
||||
private bool? _recoveryIncludesSnapshot;
|
||||
private int _recoverySnapshotIndex;
|
||||
private int _messageCount;
|
||||
|
||||
public TaskCompletionSource<string> ResponseId { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public TaskCompletionSource CrashPointReached { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public List<string> CompletedTexts { get; } = [];
|
||||
|
||||
public ResponseContinuationToken? ContinuationToken { get; private set; }
|
||||
|
||||
public bool ResponseCompleted { get; private set; }
|
||||
|
||||
public void BeginRecovery()
|
||||
{
|
||||
this._preCrashTexts = [.. this.CompletedTexts];
|
||||
this._recoveryIncludesSnapshot = null;
|
||||
this._recoverySnapshotIndex = 0;
|
||||
}
|
||||
|
||||
public void ObserveInitial(AgentResponseUpdate update) =>
|
||||
this.Observe(update, "before", trackCheckpoint: true);
|
||||
|
||||
public void ObserveRecovered(AgentResponseUpdate update) =>
|
||||
this.Observe(update, "recovered", trackCheckpoint: false);
|
||||
|
||||
public void ObserveReplayed(AgentResponseUpdate update) =>
|
||||
this.Observe(update, "replayed", trackCheckpoint: false);
|
||||
|
||||
private void Observe(
|
||||
AgentResponseUpdate update,
|
||||
string phase,
|
||||
bool trackCheckpoint)
|
||||
{
|
||||
object? rawRepresentation =
|
||||
update.RawRepresentation is ChatResponseUpdate chatResponseUpdate
|
||||
? chatResponseUpdate.RawRepresentation
|
||||
: update.RawRepresentation;
|
||||
|
||||
if (update.ContinuationToken is { } continuationToken)
|
||||
{
|
||||
this.ContinuationToken = continuationToken;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(update.ResponseId))
|
||||
{
|
||||
this.ResponseId.TrySetResult(update.ResponseId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(update.MessageId)
|
||||
&& !string.IsNullOrEmpty(update.Text))
|
||||
{
|
||||
if (!this._messageBuffers.TryGetValue(
|
||||
update.MessageId,
|
||||
out StringBuilder? buffer))
|
||||
{
|
||||
buffer = new StringBuilder();
|
||||
this._messageBuffers[update.MessageId] = buffer;
|
||||
}
|
||||
|
||||
buffer.Append(update.Text);
|
||||
}
|
||||
|
||||
if (rawRepresentation is StreamingResponseOutputItemDoneUpdate
|
||||
{
|
||||
Item: MessageResponseItem message
|
||||
}
|
||||
&& this._completedMessageIds.Add(message.Id))
|
||||
{
|
||||
string text = this._messageBuffers.TryGetValue(
|
||||
message.Id,
|
||||
out StringBuilder? buffer)
|
||||
? buffer.ToString()
|
||||
: string.Empty;
|
||||
if (phase != "before" && text.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase == "recovered"
|
||||
&& this.TryHandleRecoverySnapshot(text))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this.CompletedTexts.Add(text);
|
||||
WriteOutput(phase, text);
|
||||
|
||||
if (trackCheckpoint && ++this._messageCount >= crashAfterCount)
|
||||
{
|
||||
this.CrashPointReached.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
if (rawRepresentation is StreamingResponseCompletedUpdate)
|
||||
{
|
||||
this.ResponseCompleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryHandleRecoverySnapshot(string text)
|
||||
{
|
||||
if (this._preCrashTexts is not { Count: > 0 } preCrashTexts)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
this._recoveryIncludesSnapshot ??=
|
||||
string.Equals(text, preCrashTexts[0], StringComparison.Ordinal);
|
||||
if (this._recoveryIncludesSnapshot is not true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._recoverySnapshotIndex >= preCrashTexts.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(
|
||||
text,
|
||||
preCrashTexts[this._recoverySnapshotIndex],
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The response snapshot returned during reconnection did not match the pre-crash output.");
|
||||
}
|
||||
|
||||
this._recoverySnapshotIndex++;
|
||||
WriteOutput("restored", text);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void WriteOutput(string phase, string text)
|
||||
{
|
||||
Console.ForegroundColor = phase == "recovered"
|
||||
? ConsoleColor.Green
|
||||
: ConsoleColor.DarkGray;
|
||||
Console.WriteLine($" {phase,-9} > {text}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ServerProcess
|
||||
{
|
||||
private readonly Process _process;
|
||||
private readonly Task _outputPump;
|
||||
private readonly Task _errorPump;
|
||||
|
||||
private ServerProcess(Process process, TextWriter logWriter)
|
||||
{
|
||||
this._process = process;
|
||||
this._outputPump = PumpAsync(process.StandardOutput, logWriter, "stdout");
|
||||
this._errorPump = PumpAsync(process.StandardError, logWriter, "stderr");
|
||||
}
|
||||
|
||||
public int Id => this._process.Id;
|
||||
|
||||
public static ServerProcess Start(
|
||||
ProcessStartInfo startInfo,
|
||||
TextWriter logWriter)
|
||||
{
|
||||
Process process = Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException("Could not start the server process.");
|
||||
return new ServerProcess(process, TextWriter.Synchronized(logWriter));
|
||||
}
|
||||
|
||||
public async Task KillAsync()
|
||||
{
|
||||
if (!this._process.HasExited)
|
||||
{
|
||||
this._process.Kill(entireProcessTree: true);
|
||||
}
|
||||
|
||||
await this._process.WaitForExitAsync();
|
||||
await Task.WhenAll(this._outputPump, this._errorPump)
|
||||
.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
this._process.Dispose();
|
||||
}
|
||||
|
||||
private static async Task PumpAsync(
|
||||
StreamReader reader,
|
||||
TextWriter writer,
|
||||
string source)
|
||||
{
|
||||
while (await reader.ReadLineAsync() is { } line)
|
||||
{
|
||||
await writer.WriteLineAsync($"[{source}] {line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LocalAgentClient(
|
||||
AIAgent agent,
|
||||
HttpClient transportClient) : IDisposable
|
||||
{
|
||||
public AIAgent Agent { get; } = agent;
|
||||
|
||||
public void Dispose() => transportClient.Dispose();
|
||||
}
|
||||
|
||||
internal sealed record VerificationOptions(
|
||||
int Target,
|
||||
int CrashAfterCount,
|
||||
int DelaySeconds)
|
||||
{
|
||||
public static VerificationOptions Parse(string[] args)
|
||||
{
|
||||
int target = 20;
|
||||
int? crashAfterCount = null;
|
||||
int delaySeconds = 1;
|
||||
|
||||
for (int index = 0; index < args.Length; index++)
|
||||
{
|
||||
string argument = args[index];
|
||||
switch (argument)
|
||||
{
|
||||
case "--target":
|
||||
target = ReadInteger(args, ref index, argument);
|
||||
break;
|
||||
case "--crash-after-count":
|
||||
crashAfterCount = ReadInteger(args, ref index, argument);
|
||||
break;
|
||||
case "--delay-seconds":
|
||||
delaySeconds = ReadInteger(args, ref index, argument);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentException($"Unknown argument '{argument}'.");
|
||||
}
|
||||
}
|
||||
|
||||
int resolvedCrashAfterCount = crashAfterCount ?? Math.Max(1, target / 2);
|
||||
if (target < 2)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(args),
|
||||
"Target must be at least 2.");
|
||||
}
|
||||
|
||||
if (resolvedCrashAfterCount < 1 || resolvedCrashAfterCount >= target)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(args),
|
||||
"Crash count must be greater than zero and less than the target.");
|
||||
}
|
||||
|
||||
if (delaySeconds < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(args),
|
||||
"Delay seconds must be zero or greater.");
|
||||
}
|
||||
|
||||
return new(target, resolvedCrashAfterCount, delaySeconds);
|
||||
}
|
||||
|
||||
private static int ReadInteger(
|
||||
string[] args,
|
||||
ref int index,
|
||||
string argument)
|
||||
{
|
||||
if (++index >= args.Length
|
||||
|| !int.TryParse(
|
||||
args[index],
|
||||
NumberStyles.None,
|
||||
CultureInfo.InvariantCulture,
|
||||
out int value))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Argument '{argument}' requires an integer value.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
# Using-E2E-Resilience
|
||||
|
||||
A self-contained local E2E demonstration for
|
||||
[`Hosted-Workflow-Resilient-Long-Running`](../Hosted-Workflow-Resilient-Long-Running/).
|
||||
It owns both server process lifetimes, consumes their response stream, and prints every countdown
|
||||
output in one console.
|
||||
|
||||
The E2E creates a MAF client agent through `AIProjectClient.AsAIAgent(model, instructions)`. It
|
||||
enables `AgentRunOptions.AllowBackgroundResponses`, consumes `AgentResponseUpdate` values, saves the
|
||||
latest non-null `ResponseContinuationToken`, and supplies that token after the replacement server
|
||||
starts. It does not implement the Responses HTTP or SSE protocol itself.
|
||||
|
||||
The demonstration uses one MAF client agent and one agent session for three calls:
|
||||
|
||||
1. Starts the hosted workflow server as a child process.
|
||||
2. Creates a stored background streaming response through the MAF agent.
|
||||
3. Prints countdown messages as MAF streaming updates arrive.
|
||||
4. Waits until the matching workflow and response checkpoint is durable.
|
||||
5. Force-kills the server process tree.
|
||||
6. Starts a replacement server over the same AgentServer state.
|
||||
7. The second call reconnects with the sequence-aware continuation token and prints only newly
|
||||
recovered messages.
|
||||
8. The third call uses the same agent and session with the same response ID but no sequence cursor,
|
||||
replaying the entire stream from the start.
|
||||
9. The E2E verifies that the client accumulator and cursor-free replay contain the same complete
|
||||
countdown.
|
||||
|
||||
## Run
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```powershell
|
||||
dotnet run --project dotnet\samples\04-hosting\FoundryHostedAgents\responses\Using-E2E-Resilience
|
||||
```
|
||||
|
||||
The E2E program starts the first server, ends it abruptly, starts the replacement server with the
|
||||
same durable state, and ends the replacement when the verification completes. A separately running
|
||||
local server may remain open: the E2E uses a random port, isolated Debug binaries, and an isolated
|
||||
AgentServer state directory.
|
||||
|
||||
No Azure project, model deployment, credentials, or second terminal is required.
|
||||
The E2E builds the server in Debug into an isolated temporary directory, so it does not reuse or
|
||||
overwrite the binaries of a separately running local server.
|
||||
|
||||
`AIProjectClient` requires an HTTPS endpoint before its bearer-token policy will run. The shared
|
||||
`LocalHttpSchemeRewriteHandler` presents HTTPS to that pipeline, then routes the request to the
|
||||
random loopback HTTP port at transport time. The handler rejects non-loopback targets.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
[1/7] Starting the first server process...
|
||||
[2/7] Starting the background countdown...
|
||||
before > 20
|
||||
before > 19
|
||||
before > 18
|
||||
...
|
||||
[4/7] Force-killing the first server process...
|
||||
[5/7] Starting a replacement server over the same durable state...
|
||||
[6/7] Reconnecting to the response stream...
|
||||
recovered > 10
|
||||
recovered > 9
|
||||
...
|
||||
recovered > Countdown complete.
|
||||
|
||||
[7/7] Replaying from the start without a sequence cursor...
|
||||
replayed > 20
|
||||
replayed > 19
|
||||
...
|
||||
replayed > Countdown complete.
|
||||
|
||||
Client retained countdown updates: 20
|
||||
Replay countdown updates: 20
|
||||
|
||||
PASS: crash recovery completed with ordered output and no missing or duplicated items.
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
```powershell
|
||||
dotnet run --project dotnet\samples\04-hosting\FoundryHostedAgents\responses\Using-E2E-Resilience -- `
|
||||
--target 30 `
|
||||
--crash-after-count 12 `
|
||||
--delay-seconds 1
|
||||
```
|
||||
|
||||
| Option | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `--target` | `20` | First countdown value. Must be at least 2. |
|
||||
| `--crash-after-count` | Half the target | Number of completed countdown messages before the crash. |
|
||||
| `--delay-seconds` | `1` | Delay between countdown steps. |
|
||||
|
||||
Server output is redirected to a temporary log whose path is printed at startup. Each run uses a
|
||||
random local port and an isolated AgentServer state directory. Successful runs delete their durable
|
||||
state. Failed runs retain state and print its path for investigation.
|
||||
|
||||
The second call's continuation token resumes after the last update consumed before the crash.
|
||||
Previously consumed countdown messages are retained in the client accumulator and are not streamed
|
||||
again. Only work after the durable checkpoint appears as `recovered`.
|
||||
|
||||
For the third call, the E2E derives another valid `ChatClientAgent` continuation token whose inner
|
||||
Responses token contains the same response ID without a sequence number. That call prints every
|
||||
persisted stream item as `replayed`.
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>UsingE2EResilience</RootNamespace>
|
||||
<AssemblyName>using-e2e-resilience</AssemblyName>
|
||||
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Core" />
|
||||
<PackageReference Include="OpenAI" />
|
||||
<PackageReference Include="System.ClientModel" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -48,6 +48,9 @@ never hits the TLS check.
|
||||
| [`Hosted-Toolbox-AuthPaths-Client/`](./Hosted-Toolbox-AuthPaths-Client/) | Hosted toolbox agents | Handles OAuth consent, function-tool approvals, and native MCP approvals. Use it with `Hosted-Toolbox-AuthPaths` or `Hosted-ToolboxMcpSkills`. |
|
||||
| [`SessionFilesClient/`](./SessionFilesClient/) | [`Hosted-Files`](../Hosted-Files/) | Same shape as `SimpleAgent`, framed around the bundled-files demo. |
|
||||
|
||||
For a self-contained crash-recovery demonstration that starts, interrupts, and restarts its own
|
||||
local server, see [`Using-E2E-Resilience`](../Using-E2E-Resilience/).
|
||||
|
||||
## Configuration (common to all clients)
|
||||
|
||||
```env
|
||||
|
||||
@@ -6,8 +6,10 @@ using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -21,6 +23,7 @@ using Microsoft.Shared.Diagnostics;
|
||||
using ResponseCompletedEvent = Azure.AI.AgentServer.Responses.Models.ResponseCompletedEvent;
|
||||
using ResponseFailedEvent = Azure.AI.AgentServer.Responses.Models.ResponseFailedEvent;
|
||||
using ResponseIncompleteEvent = Azure.AI.AgentServer.Responses.Models.ResponseIncompleteEvent;
|
||||
using ResponseOutputItemDoneEvent = Azure.AI.AgentServer.Responses.Models.ResponseOutputItemDoneEvent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
@@ -32,10 +35,19 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
{
|
||||
private const string LatestWorkflowCheckpointIdMetadataKey = "_last_checkpoint_id";
|
||||
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<AgentFrameworkResponseHandler> _logger;
|
||||
private readonly FoundryToolboxService? _toolboxService;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the host was configured for durable long-running (resilient) background responses
|
||||
/// (<see cref="FoundryResponsesOptions.ResilientBackground"/>). When <see langword="false"/> the
|
||||
/// handler never does mid-turn session saves or recovery exit and behaves exactly as a non-resilient host.
|
||||
/// </summary>
|
||||
private readonly bool _resilientBackground;
|
||||
|
||||
/// <summary>
|
||||
/// Cached fallback used when no <see cref="HostedSessionIsolationKeyProvider"/> is registered in DI.
|
||||
/// Avoids a per-request allocation on the request hot path.
|
||||
@@ -53,13 +65,39 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<AgentFrameworkResponseHandler> logger,
|
||||
FoundryToolboxService? toolboxService = null)
|
||||
: this(
|
||||
serviceProvider,
|
||||
logger,
|
||||
Options.Create(new FoundryResponsesOptions()),
|
||||
toolboxService)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentFrameworkResponseHandler"/> class
|
||||
/// that resolves agents from keyed DI services.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">The service provider for resolving agents.</param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
/// <param name="foundryResponsesOptions">
|
||||
/// Hosting options, used to read whether resilient background responses are enabled.
|
||||
/// </param>
|
||||
/// <param name="toolboxService">Optional Foundry Toolbox service providing MCP tools.</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public AgentFrameworkResponseHandler(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<AgentFrameworkResponseHandler> logger,
|
||||
IOptions<FoundryResponsesOptions> foundryResponsesOptions,
|
||||
FoundryToolboxService? toolboxService = null)
|
||||
{
|
||||
_ = Throw.IfNull(serviceProvider);
|
||||
_ = Throw.IfNull(logger);
|
||||
_ = Throw.IfNull(foundryResponsesOptions);
|
||||
|
||||
this._serviceProvider = serviceProvider;
|
||||
this._logger = logger;
|
||||
this._toolboxService = toolboxService;
|
||||
this._resilientBackground = foundryResponsesOptions.Value.ResilientBackground;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -118,6 +156,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
// nothing is persisted for the key, so a fresh conversation and a resumed one both end up with
|
||||
// a session to run against.
|
||||
AgentSession? session;
|
||||
bool sessionRestoredFromStore = false;
|
||||
if (string.IsNullOrWhiteSpace(agentSessionId))
|
||||
{
|
||||
session = await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -130,6 +169,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
resolvedUserId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
sessionRestoredFromStore = session is not null;
|
||||
session ??= await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -163,8 +203,31 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create the SDK event stream builder
|
||||
var stream = new ResponseEventStream(context, request);
|
||||
// 3. Create the SDK event stream builder.
|
||||
// On recovery, AgentServer supplies the last ResponseObject snapshot that it persisted.
|
||||
// Workflow response checkpoints carry the exact workflow checkpoint id represented by that
|
||||
// snapshot, so recovery can select the matching workflow boundary rather than a newer
|
||||
// checkpoint that may already exist in workflow storage.
|
||||
var stream = context.IsRecovery && context.PersistedResponse is { } persistedResponse
|
||||
? new ResponseEventStream(context, persistedResponse)
|
||||
: new ResponseEventStream(context, request);
|
||||
|
||||
WorkflowSessionCheckpointRecovery? workflowCheckpointRecovery =
|
||||
session?.GetService<WorkflowSessionCheckpointRecovery>();
|
||||
if (context.IsRecovery
|
||||
&& sessionRestoredFromStore
|
||||
&& workflowCheckpointRecovery is not null)
|
||||
{
|
||||
string? checkpointId =
|
||||
stream.InternalMetadata.TryGetValue(LatestWorkflowCheckpointIdMetadataKey, out string? persistedCheckpointId)
|
||||
&& !string.IsNullOrWhiteSpace(persistedCheckpointId)
|
||||
? persistedCheckpointId
|
||||
: null;
|
||||
|
||||
// When metadata is absent, TryPrepare keeps the checkpoint already referenced by the
|
||||
// restored session. Either path continues queued work without starting a new turn.
|
||||
workflowCheckpointRecovery.TryPrepare(checkpointId);
|
||||
}
|
||||
|
||||
// 3. Emit lifecycle events
|
||||
yield return stream.EmitCreated();
|
||||
@@ -172,18 +235,26 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
|
||||
// 4. Convert input: the current input items become the run's messages. Earlier turns are not
|
||||
// added here; whatever holds the history for this agent supplies them, see step 5.
|
||||
//
|
||||
// On recovery the platform re-delivers the original input. When a persisted AgentSession was
|
||||
// restored, this adapter leaves the message list empty and lets that session define re-entry.
|
||||
// If no session was ever saved, there is no resumable MAF state, so recovery restarts from the
|
||||
// original input instead of invoking a fresh session with no messages.
|
||||
bool shouldInjectRequestInput = !context.IsRecovery || !sessionRestoredFromStore;
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
// Load and convert current input items
|
||||
var inputItems = await context.GetInputItemsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (inputItems.Count > 0)
|
||||
if (shouldInjectRequestInput)
|
||||
{
|
||||
messages.AddRange(InputConverter.ConvertItemsToMessages(inputItems, session?.StateBag));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to raw request input
|
||||
messages.AddRange(InputConverter.ConvertInputToMessages(request, session?.StateBag));
|
||||
// Load and convert current input items
|
||||
var inputItems = await context.GetInputItemsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (inputItems.Count > 0)
|
||||
{
|
||||
messages.AddRange(InputConverter.ConvertItemsToMessages(inputItems, session?.StateBag));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to raw request input
|
||||
messages.AddRange(InputConverter.ConvertInputToMessages(request, session?.StateBag));
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Build chat options
|
||||
@@ -337,9 +408,13 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
|
||||
var options = new ChatClientAgentRunOptions(chatOptions);
|
||||
|
||||
// We only use a volatile provider for the conversation history if the agent is a ChatClientAgent and the allow setting is not intentionally set or not custom chat history provider is intentionally supplied.
|
||||
// We only use a volatile provider for the conversation history if the agent is a
|
||||
// ChatClientAgent, stored output is not allowed, and no custom history provider was supplied.
|
||||
// Recovery does not reload platform history because the restored AgentSession owns re-entry
|
||||
// state. For workflows, that includes the workflow checkpoint reference.
|
||||
var useVolatileChatHistoryProvider =
|
||||
!allowStoredOutputEnabled
|
||||
shouldInjectRequestInput
|
||||
&& !allowStoredOutputEnabled
|
||||
&& agent.GetService<ChatClientAgent>() is not null
|
||||
&& agentOptions?.ChatHistoryProvider is null;
|
||||
|
||||
@@ -358,13 +433,22 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
// We create a linked CTS so the consent-aware tool wrapper can cancel the agent
|
||||
// run mid-loop when a -32006 error is returned by the proxy. The RequestConsentState
|
||||
// is a shared mutable object that flows via AsyncLocal to the tool wrapper.
|
||||
using var consentCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
using var consentCts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken,
|
||||
context.Shutdown);
|
||||
var consentState = new RequestConsentState { CancellationSource = consentCts };
|
||||
McpConsentContext.Current.Value = consentState;
|
||||
|
||||
// 7. Run the agent and convert output
|
||||
// NOTE: C# forbids 'yield return' inside a try block that has a catch clause,
|
||||
// and inside catch blocks. We use a flag to defer the yield to outside the try/catch.
|
||||
//
|
||||
// On a resilient turn, save the AgentSession after completed response output items so a
|
||||
// process crash can reload a recent session snapshot. Workflow supersteps use a stronger
|
||||
// boundary below: save the session, record its workflow checkpoint id in internal response
|
||||
// metadata, then ask AgentServer to persist the matching ResponseObject snapshot.
|
||||
bool isResilientTurn = this.ShouldPersistForResilience(request) || context.IsRecovery;
|
||||
|
||||
bool emittedTerminal = false;
|
||||
bool notAllowedStoreUsageDetected = false;
|
||||
|
||||
@@ -375,6 +459,49 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
|
||||
// A successful terminal event, held until the run is wound up and the session can be checked.
|
||||
ResponseStreamEvent? completedEvent = null;
|
||||
bool steeringDetected = false;
|
||||
bool deferredForRecovery = false;
|
||||
|
||||
async ValueTask<ResponseStreamEvent?> PersistWorkflowCheckpointAsync(
|
||||
CheckpointInfo checkpoint,
|
||||
CancellationToken checkpointCancellationToken)
|
||||
{
|
||||
if (!isResilientTurn
|
||||
|| workflowCheckpointRecovery is null
|
||||
|| session is null
|
||||
|| string.IsNullOrWhiteSpace(agentSessionId)
|
||||
|| (stream.InternalMetadata.TryGetValue(LatestWorkflowCheckpointIdMetadataKey, out string? lastCheckpointId)
|
||||
&& string.Equals(lastCheckpointId, checkpoint.CheckpointId, StringComparison.Ordinal)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await sessionStore.SaveSessionAsync(
|
||||
agent,
|
||||
agentSessionId,
|
||||
session,
|
||||
resolvedUserId,
|
||||
checkpointCancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
this._logger.LogDebug(
|
||||
ex,
|
||||
"Workflow checkpoint {CheckpointId} was not paired with response {ResponseId} because its AgentSession could not be saved.",
|
||||
checkpoint.CheckpointId,
|
||||
context.ResponseId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
stream.InternalMetadata[LatestWorkflowCheckpointIdMetadataKey] = checkpoint.CheckpointId;
|
||||
return stream.EmitInProgress();
|
||||
}
|
||||
|
||||
// Check whenever the agent is storing messages when it should not.
|
||||
bool CheckNotAllowedStoreUsage() =>
|
||||
@@ -386,7 +513,8 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
agent.RunStreamingAsync(messages, session, options: options, cancellationToken: consentCts.Token),
|
||||
stream,
|
||||
session?.StateBag,
|
||||
cancellationToken).GetAsyncEnumerator(cancellationToken);
|
||||
persistWorkflowCheckpointHandler: PersistWorkflowCheckpointAsync,
|
||||
cancellationToken: cancellationToken).GetAsyncEnumerator(cancellationToken);
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
@@ -408,6 +536,8 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
}
|
||||
|
||||
evt = enumerator.Current;
|
||||
shutdownDetected =
|
||||
context.IsShutdownRequested && !emittedTerminal;
|
||||
}
|
||||
catch (OperationCanceledException) when (!emittedTerminal && consentState.Pending is not null)
|
||||
{
|
||||
@@ -418,6 +548,10 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
{
|
||||
shutdownDetected = true;
|
||||
}
|
||||
catch (OperationCanceledException) when (context.PendingInputCount > 0 && !emittedTerminal)
|
||||
{
|
||||
steeringDetected = true;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException && !emittedTerminal)
|
||||
{
|
||||
// Catch agent execution errors and emit a proper failed event
|
||||
@@ -467,12 +601,40 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
|
||||
if (shutdownDetected)
|
||||
{
|
||||
// Server is shutting down — emit incomplete so clients can resume
|
||||
// Server is shutting down. On a resilient turn, leave the response in_progress
|
||||
// so AgentServer can re-invoke this handler in a later process. The restored
|
||||
// AgentSession determines how the agent continues. On a non-resilient turn,
|
||||
// preserve the existing behavior and emit incomplete.
|
||||
if (isResilientTurn)
|
||||
{
|
||||
this._logger.LogInformation("Shutdown detected on a resilient turn; deferring for recovery.");
|
||||
deferredForRecovery = true;
|
||||
await context.ExitForRecoveryAsync(cancellationToken).ConfigureAwait(false);
|
||||
yield break;
|
||||
}
|
||||
|
||||
this._logger.LogInformation("Shutdown detected, emitting incomplete response.");
|
||||
yield return stream.EmitIncomplete();
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (steeringDetected)
|
||||
{
|
||||
// AgentServer cancelled this active turn because another input is queued for the
|
||||
// same conversation. Finish the current response cleanly so Core can drain the
|
||||
// queued input as a new handler invocation. The MAF AgentSession is saved in the
|
||||
// outer finally block and becomes the starting state for that invocation.
|
||||
if (this._logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
this._logger.LogInformation(
|
||||
"Steering input detected for response {ResponseId}; completing the active turn.",
|
||||
context.ResponseId);
|
||||
}
|
||||
emittedTerminal = true;
|
||||
yield return stream.EmitCompleted();
|
||||
yield break;
|
||||
}
|
||||
|
||||
// A completed event is held back rather than sent straight out. The id of any
|
||||
// conversation the agent's own service kept only lands on the session once the run is
|
||||
// fully wound up, which is after this point, so sending the event now could tell the
|
||||
@@ -484,9 +646,41 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
continue;
|
||||
}
|
||||
|
||||
// Emit the output boundary before saving the matching MAF session. AgentServer
|
||||
// persists the event while this iterator is suspended. Reversing this order could
|
||||
// advance the session past output that the caller never received. A crash after the
|
||||
// event but before the save can replay work, which is the deliberate at-least-once
|
||||
// side of this cross-store boundary.
|
||||
// yield is in the outer try (finally-only) — allowed by C#
|
||||
yield return evt!;
|
||||
|
||||
// Best-effort session snapshot for a non-workflow agent after a response output item
|
||||
// closes. Workflow agents save only at the paired superstep boundary so their session
|
||||
// cursor cannot advance independently of the response snapshot. The final save below
|
||||
// remains authoritative for a turn that reaches normal completion.
|
||||
if (isResilientTurn
|
||||
&& evt is ResponseOutputItemDoneEvent
|
||||
&& workflowCheckpointRecovery is null
|
||||
&& session is not null
|
||||
&& !string.IsNullOrWhiteSpace(agentSessionId)
|
||||
&& !turnFailed)
|
||||
{
|
||||
try
|
||||
{
|
||||
await sessionStore.SaveSessionAsync(agent, agentSessionId!, session, resolvedUserId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
this._logger.LogDebug(
|
||||
ex,
|
||||
"Incremental session save was skipped for response {ResponseId}; the end-of-turn save will persist the final state.",
|
||||
context.ResponseId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (evt is ResponseFailedEvent or ResponseIncompleteEvent)
|
||||
{
|
||||
emittedTerminal = true;
|
||||
@@ -504,15 +698,16 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
turnFailed = true;
|
||||
}
|
||||
|
||||
// Persist the session for the next turn of this conversation, unless this one is being failed.
|
||||
if (session is not null && !turnFailed)
|
||||
// Persist the session for the next turn unless this turn failed or deferred after the
|
||||
// agent advanced beyond the last event emitted to AgentServer.
|
||||
if (session is not null && !turnFailed && !deferredForRecovery)
|
||||
{
|
||||
await sessionStore.SaveSessionAsync(
|
||||
agent,
|
||||
agentSessionId!,
|
||||
session,
|
||||
resolvedUserId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
steeringDetected ? CancellationToken.None : cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,6 +768,16 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
return "oacr_" + Convert.ToHexString(bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The resilience gate for the mid-turn session-save path: saving is worthwhile only when the
|
||||
/// host enabled resilient background responses and this specific request is a background response
|
||||
/// that did not explicitly disable storage. A null <c>store</c> value means the Responses API
|
||||
/// default of true. When any part is false, the request runs exactly as it does on a non-resilient
|
||||
/// host (the recovery path is gated separately on <c>ResponseContext.IsRecovery</c>).
|
||||
/// </summary>
|
||||
private bool ShouldPersistForResilience(CreateResponse request)
|
||||
=> this._resilientBackground && request.Background == true && request.Store != false;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an <see cref="AIAgent"/> from the request.
|
||||
/// Tries <c>agent.name</c> first, then falls back to <c>metadata["entity_id"]</c>.
|
||||
|
||||
@@ -63,10 +63,10 @@ public sealed class FoundryJsonCheckpointStore : JsonCheckpointStore
|
||||
public const string DefaultStoreName = "agent-framework/checkpoints";
|
||||
|
||||
/// <summary>
|
||||
/// How many times a losing index update is retried before giving up. Each attempt re-reads the
|
||||
/// index, so a retry only happens when another writer committed a checkpoint in between.
|
||||
/// The default number of attempts to update a workflow checkpoint index after concurrent writers
|
||||
/// modify it.
|
||||
/// </summary>
|
||||
private const int MaxIndexUpdateAttempts = 8;
|
||||
public const int DefaultMaxIndexUpdateAttempts = 8;
|
||||
|
||||
/// <summary>The item-body field holding the serialized checkpoint JSON.</summary>
|
||||
private const string CheckpointField = "checkpoint";
|
||||
@@ -83,6 +83,7 @@ public sealed class FoundryJsonCheckpointStore : JsonCheckpointStore
|
||||
|
||||
private readonly FoundryStateStoreBinding _binding;
|
||||
private readonly ILogger? _logger;
|
||||
private readonly int _maxIndexUpdateAttempts;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FoundryJsonCheckpointStore"/> class.
|
||||
@@ -114,10 +115,43 @@ public sealed class FoundryJsonCheckpointStore : JsonCheckpointStore
|
||||
string storeName = DefaultStoreName,
|
||||
int itemTtlSeconds = FoundryStateStore.DefaultItemTtlSeconds,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
: this(
|
||||
DefaultMaxIndexUpdateAttempts,
|
||||
endpoint,
|
||||
credential,
|
||||
storeName,
|
||||
itemTtlSeconds,
|
||||
loggerFactory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FoundryJsonCheckpointStore"/> class with a
|
||||
/// configurable checkpoint-index update limit.
|
||||
/// </summary>
|
||||
/// <param name="maxIndexUpdateAttempts">
|
||||
/// The maximum number of attempts to update a workflow checkpoint index when another writer
|
||||
/// modifies it concurrently. Each retry re-reads the index before writing. Must be greater than
|
||||
/// zero.
|
||||
/// </param>
|
||||
/// <param name="endpoint">The Foundry project endpoint, or <see langword="null"/> to resolve it from the environment.</param>
|
||||
/// <param name="credential">The credential used for hosted state storage. May be <see langword="null"/> outside Foundry.</param>
|
||||
/// <param name="storeName">The state-store name to hold the checkpoints.</param>
|
||||
/// <param name="itemTtlSeconds">How long a checkpoint survives without being written, in seconds.</param>
|
||||
/// <param name="loggerFactory">Creates the logger this store reports through.</param>
|
||||
public FoundryJsonCheckpointStore(
|
||||
int maxIndexUpdateAttempts,
|
||||
Uri? endpoint = null,
|
||||
TokenCredential? credential = null,
|
||||
string storeName = DefaultStoreName,
|
||||
int itemTtlSeconds = FoundryStateStore.DefaultItemTtlSeconds,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
_ = Throw.IfNullOrWhitespace(storeName);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(maxIndexUpdateAttempts, 1);
|
||||
|
||||
this.StoreName = storeName;
|
||||
this._maxIndexUpdateAttempts = maxIndexUpdateAttempts;
|
||||
this._logger = loggerFactory?.CreateLogger<FoundryJsonCheckpointStore>();
|
||||
this._binding = new(cancellationToken => FoundryStateStore.GetOrCreateAsync(
|
||||
storeName,
|
||||
@@ -135,15 +169,22 @@ public sealed class FoundryJsonCheckpointStore : JsonCheckpointStore
|
||||
/// <param name="storeFactory">Resolves the bound state store on first use.</param>
|
||||
/// <param name="storeName">The state-store name, for diagnostics.</param>
|
||||
/// <param name="loggerFactory">Creates the logger this store reports through.</param>
|
||||
/// <param name="maxIndexUpdateAttempts">
|
||||
/// The maximum number of attempts to update a workflow checkpoint index after a concurrent
|
||||
/// modification.
|
||||
/// </param>
|
||||
internal FoundryJsonCheckpointStore(
|
||||
Func<CancellationToken, Task<FoundryStateStore>> storeFactory,
|
||||
string storeName = DefaultStoreName,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
int maxIndexUpdateAttempts = DefaultMaxIndexUpdateAttempts)
|
||||
{
|
||||
_ = Throw.IfNull(storeFactory);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(maxIndexUpdateAttempts, 1);
|
||||
|
||||
this._binding = new(storeFactory);
|
||||
this.StoreName = storeName;
|
||||
this._maxIndexUpdateAttempts = maxIndexUpdateAttempts;
|
||||
this._logger = loggerFactory?.CreateLogger<FoundryJsonCheckpointStore>();
|
||||
}
|
||||
|
||||
@@ -176,7 +217,7 @@ public sealed class FoundryJsonCheckpointStore : JsonCheckpointStore
|
||||
|
||||
// Announce the stored checkpoint by appending its identifier to the session's index, giving
|
||||
// way and reading again whenever another instance updated that same index first.
|
||||
for (int attempt = 0; attempt < MaxIndexUpdateAttempts; attempt++)
|
||||
for (int attempt = 0; attempt < this._maxIndexUpdateAttempts; attempt++)
|
||||
{
|
||||
StateStoreItem? indexItem = await store.GetItemAsync(sessionIndexKey, CancellationToken.None).ConfigureAwait(false);
|
||||
List<IndexEntry> entries = ReadEntries(indexItem);
|
||||
@@ -207,7 +248,7 @@ public sealed class FoundryJsonCheckpointStore : JsonCheckpointStore
|
||||
ex,
|
||||
"Attempt {Attempt} of {MaxAttempts} to index checkpoint '{CheckpointId}' for session '{SessionId}' lost to another writer. Retrying.",
|
||||
attempt + 1,
|
||||
MaxIndexUpdateAttempts,
|
||||
this._maxIndexUpdateAttempts,
|
||||
checkpointInfo.CheckpointId,
|
||||
sessionId);
|
||||
}
|
||||
@@ -217,7 +258,7 @@ public sealed class FoundryJsonCheckpointStore : JsonCheckpointStore
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Could not add a checkpoint for session '{sessionId}' to the Foundry state store after {MaxIndexUpdateAttempts} attempts because other writers kept updating the same session index.");
|
||||
$"Could not add a checkpoint for session '{sessionId}' to the Foundry state store after {this._maxIndexUpdateAttempts} attempts because other writers kept updating the same session index.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -50,4 +50,50 @@ public sealed class FoundryResponsesOptions
|
||||
/// Default is <see langword="true"/>.
|
||||
/// </value>
|
||||
public bool IncludeReasoningEncryptedContent { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether background responses are resilient to process crashes
|
||||
/// and graceful shutdown.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When <see langword="true"/>, accepted background responses (<c>background=true</c> and
|
||||
/// <c>store</c> omitted or <see langword="true"/>) are registered with the durable task subsystem
|
||||
/// so a handler interrupted by a crash or shutdown is
|
||||
/// re-invoked in a subsequent process lifetime with the original request context restored
|
||||
/// (<c>ResponseContext.IsRecovery</c> is <see langword="true"/>). AgentServer supplies its last durable
|
||||
/// response snapshot. For workflow agents, the hosting handler pairs completed supersteps with response
|
||||
/// checkpoints and records the matching workflow checkpoint ID in AgentServer internal response metadata.
|
||||
/// Recovery restores the AgentSession, selects that exact workflow checkpoint, skips re-injecting the
|
||||
/// original input, and defers on shutdown instead of ending the response as incomplete. Regular agents
|
||||
/// continue to depend on their serialized AgentSession state.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When <see langword="false"/> (the default), an interrupted background response transitions to a
|
||||
/// failed terminal state and is not re-invoked. The hosting handler does not perform resilient
|
||||
/// mid-turn session saves or shutdown deferral.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This value is forwarded to
|
||||
/// <see cref="Azure.AI.AgentServer.Responses.ResponsesServerOptions.ResilientBackground"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <value>
|
||||
/// Default is <see langword="false"/>.
|
||||
/// </value>
|
||||
public bool ResilientBackground { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether in-flight conversations accept steering (mid-turn
|
||||
/// additional input) sharing a single resilient task.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Forwarded to
|
||||
/// <see cref="Azure.AI.AgentServer.Responses.ResponsesServerOptions.SteerableConversations"/>.
|
||||
/// When <see langword="false"/> (the default), steering is disabled.
|
||||
/// </remarks>
|
||||
/// <value>
|
||||
/// Default is <see langword="false"/>.
|
||||
/// </value>
|
||||
public bool SteerableConversations { get; set; }
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ internal static class OutputConverter
|
||||
/// <param name="updates">The agent response updates to convert.</param>
|
||||
/// <param name="stream">The SDK event stream builder.</param>
|
||||
/// <param name="stateBag">Optional session state bag used to persist tool-approval id mappings across turns.</param>
|
||||
/// <param name="persistWorkflowCheckpointHandler">
|
||||
/// Optional callback invoked after all output from a completed workflow superstep has been closed.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>An async enumerable of SDK response stream events (excluding lifecycle events).</returns>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call arguments dictionary.")]
|
||||
@@ -40,6 +43,7 @@ internal static class OutputConverter
|
||||
IAsyncEnumerable<AgentResponseUpdate> updates,
|
||||
ResponseEventStream stream,
|
||||
AgentSessionStateBag? stateBag = null,
|
||||
Func<CheckpointInfo, CancellationToken, ValueTask<ResponseStreamEvent?>>? persistWorkflowCheckpointHandler = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ResponseUsage? accumulatedUsage = null;
|
||||
@@ -78,6 +82,21 @@ internal static class OutputConverter
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
if (workflowEvent is SuperStepCompletedEvent { CompletionInfo.Checkpoint: { } checkpoint }
|
||||
&& persistWorkflowCheckpointHandler is not null)
|
||||
{
|
||||
ResponseStreamEvent? checkpointStateEvent =
|
||||
await persistWorkflowCheckpointHandler(checkpoint, cancellationToken).ConfigureAwait(false);
|
||||
if (checkpointStateEvent is not null)
|
||||
{
|
||||
// AgentServer persists its orchestrator-owned response snapshot. Emit the
|
||||
// updated response state first so internal metadata becomes part of that
|
||||
// authoritative snapshot, then persist it with the control event.
|
||||
yield return checkpointStateEvent;
|
||||
yield return stream.Checkpoint();
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,18 +58,27 @@ public static class FoundryHostingExtensions
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configure">
|
||||
/// Optional callback to configure <see cref="FoundryResponsesOptions"/>, for example to allow the
|
||||
/// agent's own service to store the responses it produces.
|
||||
/// agent's own service to store the responses it produces, or to opt in to durable long-running
|
||||
/// (resilient) background responses via <see cref="FoundryResponsesOptions.ResilientBackground"/>.
|
||||
/// </param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFoundryResponses(this IServiceCollection services, Action<FoundryResponsesOptions>? configure = null)
|
||||
{
|
||||
_ = Throw.IfNull(services);
|
||||
AddResponsesServerOnce(services);
|
||||
FoundryResponsesOptions configuredOptions = CreateFoundryResponsesOptions(configure);
|
||||
bool serverAdded = AddResponsesServerOnce(
|
||||
services,
|
||||
configuredOptions,
|
||||
configure is not null);
|
||||
services.AddHealthChecks();
|
||||
ConfigureFoundryListenPort(services);
|
||||
ConfigureFoundryResponsesOptions(services, configure);
|
||||
ConfigureFoundryResponsesOptions(
|
||||
services,
|
||||
configuredOptions,
|
||||
includeServerOptions: serverAdded,
|
||||
applyOptions: serverAdded || configure is not null);
|
||||
services.TryAddSingleton<AgentSessionStore>(_ => CreateDefaultAgentSessionStore());
|
||||
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
|
||||
RegisterResponseHandler(services);
|
||||
MarkFeatureUsed();
|
||||
return services;
|
||||
}
|
||||
@@ -99,7 +108,8 @@ public static class FoundryHostingExtensions
|
||||
/// <param name="agentSessionStore">The agent session store to use for managing agent sessions server-side. If null, <see cref="FoundryAgentSessionStore"/> is used: the Foundry durable state store when hosted, and the AgentServer SDK's local state-store fallback otherwise.</param>
|
||||
/// <param name="configure">
|
||||
/// Optional callback to configure <see cref="FoundryResponsesOptions"/>, for example to allow the
|
||||
/// agent's own service to store the responses it produces.
|
||||
/// agent's own service to store the responses it produces, or to opt in to durable long-running
|
||||
/// (resilient) background responses via <see cref="FoundryResponsesOptions.ResilientBackground"/>.
|
||||
/// </param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFoundryResponses(
|
||||
@@ -111,10 +121,18 @@ public static class FoundryHostingExtensions
|
||||
_ = Throw.IfNull(services);
|
||||
_ = Throw.IfNull(agent);
|
||||
|
||||
AddResponsesServerOnce(services);
|
||||
FoundryResponsesOptions configuredOptions = CreateFoundryResponsesOptions(configure);
|
||||
bool serverAdded = AddResponsesServerOnce(
|
||||
services,
|
||||
configuredOptions,
|
||||
configure is not null);
|
||||
services.AddHealthChecks();
|
||||
ConfigureFoundryListenPort(services);
|
||||
ConfigureFoundryResponsesOptions(services, configure);
|
||||
ConfigureFoundryResponsesOptions(
|
||||
services,
|
||||
configuredOptions,
|
||||
includeServerOptions: serverAdded,
|
||||
applyOptions: serverAdded || configure is not null);
|
||||
agentSessionStore ??= CreateDefaultAgentSessionStore();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(agent.Name))
|
||||
@@ -128,7 +146,7 @@ public static class FoundryHostingExtensions
|
||||
services.TryAddSingleton(agent);
|
||||
services.TryAddSingleton(agentSessionStore);
|
||||
|
||||
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
|
||||
RegisterResponseHandler(services);
|
||||
MarkFeatureUsed();
|
||||
return services;
|
||||
}
|
||||
@@ -142,12 +160,44 @@ public static class FoundryHostingExtensions
|
||||
/// The checks are registered on the same <c>/readiness</c> pipeline that <see cref="MapFoundryResponses"/>
|
||||
/// maps, so such a container never takes traffic.
|
||||
/// <c>AddCheck</c> does not dedupe by name, so a repeated registration is guarded here.
|
||||
/// Resilience flags on <see cref="FoundryResponsesOptions"/> are forwarded to
|
||||
/// <see cref="ResponsesServerOptions"/> so the AgentServer SDK enables recovery for the same host.
|
||||
/// </remarks>
|
||||
private static void ConfigureFoundryResponsesOptions(IServiceCollection services, Action<FoundryResponsesOptions>? configure)
|
||||
private static FoundryResponsesOptions CreateFoundryResponsesOptions(Action<FoundryResponsesOptions>? configure)
|
||||
{
|
||||
if (configure is not null)
|
||||
FoundryResponsesOptions options = new();
|
||||
configure?.Invoke(options);
|
||||
return options;
|
||||
}
|
||||
|
||||
private static void RegisterResponseHandler(IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<ResponseHandler>(serviceProvider =>
|
||||
new AgentFrameworkResponseHandler(
|
||||
serviceProvider,
|
||||
serviceProvider.GetRequiredService<ILogger<AgentFrameworkResponseHandler>>(),
|
||||
serviceProvider.GetRequiredService<IOptions<FoundryResponsesOptions>>(),
|
||||
serviceProvider.GetService<FoundryToolboxService>()));
|
||||
}
|
||||
|
||||
private static void ConfigureFoundryResponsesOptions(
|
||||
IServiceCollection services,
|
||||
FoundryResponsesOptions configuredOptions,
|
||||
bool includeServerOptions,
|
||||
bool applyOptions)
|
||||
{
|
||||
if (applyOptions)
|
||||
{
|
||||
services.Configure(configure);
|
||||
services.Configure<FoundryResponsesOptions>(options =>
|
||||
{
|
||||
options.AllowStoredOutputEnabled = configuredOptions.AllowStoredOutputEnabled;
|
||||
options.IncludeReasoningEncryptedContent = configuredOptions.IncludeReasoningEncryptedContent;
|
||||
if (includeServerOptions)
|
||||
{
|
||||
options.ResilientBackground = configuredOptions.ResilientBackground;
|
||||
options.SteerableConversations = configuredOptions.SteerableConversations;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
AddReadinessCheckOnce(services, "foundry-stored-output", sp => ActivatorUtilities.CreateInstance<HostedStoredOutputHealthCheck>(sp));
|
||||
@@ -358,15 +408,37 @@ public static class FoundryHostingExtensions
|
||||
/// a host that registers several agents naturally does, so the second and later calls are
|
||||
/// skipped here.
|
||||
/// </remarks>
|
||||
private static void AddResponsesServerOnce(IServiceCollection services)
|
||||
private static bool AddResponsesServerOnce(
|
||||
IServiceCollection services,
|
||||
FoundryResponsesOptions configuredOptions,
|
||||
bool hasConfigureCallback)
|
||||
{
|
||||
if (services.Any(static d => d.ServiceType == typeof(FoundryResponsesServerMarker)))
|
||||
FoundryResponsesServerMarker? marker = services
|
||||
.LastOrDefault(static descriptor =>
|
||||
descriptor.ServiceType == typeof(FoundryResponsesServerMarker))
|
||||
?.ImplementationInstance as FoundryResponsesServerMarker;
|
||||
if (marker is not null)
|
||||
{
|
||||
return;
|
||||
if (hasConfigureCallback
|
||||
&& ((!marker.ResilientBackground && configuredOptions.ResilientBackground)
|
||||
|| (!marker.SteerableConversations && configuredOptions.SteerableConversations)))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"ResilientBackground and SteerableConversations must be configured on the first AddFoundryResponses call because AgentServer registers its durable tasks during that call.");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
services.AddSingleton<FoundryResponsesServerMarker>();
|
||||
services.AddResponsesServer();
|
||||
services.AddSingleton(new FoundryResponsesServerMarker(
|
||||
configuredOptions.ResilientBackground,
|
||||
configuredOptions.SteerableConversations));
|
||||
services.AddResponsesServer(options =>
|
||||
{
|
||||
options.ResilientBackground = configuredOptions.ResilientBackground;
|
||||
options.SteerableConversations = configuredOptions.SteerableConversations;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -407,7 +479,14 @@ public static class FoundryHostingExtensions
|
||||
/// Marker registered once per <see cref="IServiceCollection"/> so the Responses Server SDK is
|
||||
/// registered at most once, even across multiple <c>AddFoundryResponses</c> calls.
|
||||
/// </summary>
|
||||
private sealed class FoundryResponsesServerMarker;
|
||||
private sealed class FoundryResponsesServerMarker(
|
||||
bool resilientBackground,
|
||||
bool steerableConversations)
|
||||
{
|
||||
public bool ResilientBackground { get; } = resilientBackground;
|
||||
|
||||
public bool SteerableConversations { get; } = steerableConversations;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binds Kestrel to the port the Foundry hosted runtime probes and routes to, so a plain
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
/// <para>
|
||||
/// Retrieve it with <c>agent.GetService<WorkflowAgentMetadata>()</c>. Getting an instance back
|
||||
/// is what identifies the agent as running a workflow; <see langword="null"/> means it does not.
|
||||
/// Going through <see cref="AIAgent.GetService(System.Type, object?)"/> means the answer is still
|
||||
/// Going through <see cref="AIAgent.GetService"/> means the answer is still
|
||||
/// found when the agent has been wrapped, by middleware for example, which a test on the type of the
|
||||
/// agent would miss.
|
||||
/// </para>
|
||||
|
||||
@@ -31,6 +31,8 @@ internal sealed class WorkflowSession : AgentSession
|
||||
private readonly bool _includeWorkflowOutputsInResponse;
|
||||
|
||||
private InMemoryCheckpointManager? _inMemoryCheckpointManager;
|
||||
private bool _resumeWithoutNewTurn;
|
||||
private WorkflowSessionCheckpointRecovery? _checkpointRecovery;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks pending external requests by their workflow-facing request ID.
|
||||
@@ -132,6 +134,31 @@ internal sealed class WorkflowSession : AgentSession
|
||||
|
||||
public CheckpointInfo? LastCheckpoint { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
return base.GetService(serviceType, serviceKey)
|
||||
?? (serviceKey is null && serviceType == typeof(WorkflowSessionCheckpointRecovery)
|
||||
? this._checkpointRecovery ??= new(this)
|
||||
: null);
|
||||
}
|
||||
|
||||
internal bool TryPrepareCheckpointRecovery(string? checkpointId)
|
||||
{
|
||||
if (checkpointId is not null)
|
||||
{
|
||||
_ = Throw.IfNullOrWhitespace(checkpointId);
|
||||
this.LastCheckpoint = new CheckpointInfo(this.SessionId, checkpointId);
|
||||
}
|
||||
else if (this.LastCheckpoint is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
this._resumeWithoutNewTurn = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
JsonMarshaller marshaller = new(jsonSerializerOptions);
|
||||
@@ -449,6 +476,8 @@ internal sealed class WorkflowSession : AgentSession
|
||||
|
||||
ResumeRunResult resumeResult =
|
||||
await this.CreateOrResumeRunAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
bool resumeWithoutNewTurn = this._resumeWithoutNewTurn;
|
||||
this._resumeWithoutNewTurn = false;
|
||||
|
||||
#pragma warning disable CA2007 // Analyzer misfiring.
|
||||
await using StreamingRun run = resumeResult.Run;
|
||||
@@ -462,8 +491,9 @@ internal sealed class WorkflowSession : AgentSession
|
||||
// ContinueTurnAsync). Non-start executors (e.g., RequestInfoExecutor) do not emit
|
||||
// TurnTokens after processing responses, so the session must always provide one.
|
||||
bool shouldSendTurnToken =
|
||||
!dispatchInfo.HasMatchedExternalResponses
|
||||
|| !dispatchInfo.HasMatchedResponseForStartExecutor;
|
||||
!resumeWithoutNewTurn
|
||||
&& (!dispatchInfo.HasMatchedExternalResponses
|
||||
|| !dispatchInfo.HasMatchedResponseForStartExecutor);
|
||||
if (shouldSendTurnToken)
|
||||
{
|
||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Prepares a workflow-backed <see cref="AgentSession"/> to continue from a workflow checkpoint.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Retrieve this service from a workflow-backed session with
|
||||
/// <c>session.GetService<WorkflowSessionCheckpointRecovery>()</c>. Other session types return
|
||||
/// <see langword="null"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This service prepares recovery of an interrupted run. It is not a general rollback mechanism.
|
||||
/// The selected checkpoint must belong to the same serialized session state, workflow definition,
|
||||
/// and checkpoint store. Selecting an older checkpoint can repeat external effects.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class WorkflowSessionCheckpointRecovery
|
||||
{
|
||||
private readonly WorkflowSession _session;
|
||||
|
||||
internal WorkflowSessionCheckpointRecovery(WorkflowSession session)
|
||||
{
|
||||
this._session = session;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the checkpoint currently selected by the workflow session.
|
||||
/// </summary>
|
||||
public CheckpointInfo? CurrentCheckpoint => this._session.LastCheckpoint;
|
||||
|
||||
/// <summary>
|
||||
/// Prepares the session to continue the work queued in a workflow checkpoint without starting
|
||||
/// a new user turn.
|
||||
/// </summary>
|
||||
/// <param name="checkpointId">
|
||||
/// The checkpoint identifier to select. When <see langword="null"/>, the session keeps its
|
||||
/// current checkpoint.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> when a checkpoint is available for recovery; otherwise
|
||||
/// <see langword="false"/>.
|
||||
/// </returns>
|
||||
public bool TryPrepare(string? checkpointId = null) =>
|
||||
this._session.TryPrepareCheckpointRecovery(checkpointId);
|
||||
}
|
||||
+1
@@ -30,6 +30,7 @@
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -46,6 +46,8 @@ AIAgent agent = scenario switch
|
||||
"session-files" => CreateSessionFilesAgent(projectClient, deployment),
|
||||
"agent-skills" => CreateAgentSkillsAgent(projectClient, deployment),
|
||||
"user-identity" => CreateUserIdentityAgent(projectClient, deployment),
|
||||
"resilient-workflow" => ResilientWorkflowAgent.Create(),
|
||||
"steerable-long-running" => new SteerableLongRunningAgent(),
|
||||
_ => throw new InvalidOperationException($"Unknown IT_SCENARIO '{scenario}'.")
|
||||
};
|
||||
|
||||
@@ -57,7 +59,12 @@ if (!string.IsNullOrEmpty(port))
|
||||
builder.WebHost.UseUrls($"http://+:{port}");
|
||||
}
|
||||
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddFoundryResponses(agent, configure: options =>
|
||||
{
|
||||
options.ResilientBackground =
|
||||
scenario is "resilient-workflow" or "steerable-long-running";
|
||||
options.SteerableConversations = scenario == "steerable-long-running";
|
||||
});
|
||||
|
||||
// toolbox-oauth-consent scenario: pre-register a Foundry toolbox whose tool source is fronted by a
|
||||
// per-user OAuth connection. IT_TOOLBOX_NAME names that toolbox (the fixture sets it). With the
|
||||
|
||||
+372
@@ -0,0 +1,372 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.TestContainer;
|
||||
|
||||
internal static class ResilientWorkflowAgent
|
||||
{
|
||||
// Agent Administration tracks the durable session, not individual process lifetimes.
|
||||
// Persist our own incarnation so recovery can prove that a different process continued the work.
|
||||
private static readonly string s_processIncarnation = Guid.NewGuid().ToString("N");
|
||||
|
||||
public static AIAgent Create()
|
||||
{
|
||||
ResilientInputExecutor input = new();
|
||||
ResilientWorkExecutor work = new();
|
||||
ResilientOutputExecutor output = new();
|
||||
ResilientCountdownExecutor countdown = new();
|
||||
ResilientCountdownCrashExecutor countdownCrash = new();
|
||||
ResilientCountdownCompleteExecutor countdownComplete = new();
|
||||
|
||||
return new WorkflowBuilder(input)
|
||||
.AddEdge(input, work)
|
||||
.AddEdge(input, countdown)
|
||||
.AddEdge(work, output)
|
||||
.AddEdge(countdown, countdown)
|
||||
.AddEdge(countdown, countdownCrash)
|
||||
.AddEdge(countdown, countdownComplete)
|
||||
.AddEdge(countdownCrash, countdown)
|
||||
.WithOutputFrom(output, countdown, countdownComplete)
|
||||
.Build()
|
||||
.AsAIAgent(
|
||||
name: "resilient-workflow-agent",
|
||||
includeExceptionDetails: true,
|
||||
includeWorkflowOutputsInResponse: true);
|
||||
}
|
||||
|
||||
private sealed class ResilientInputExecutor()
|
||||
: ChatProtocolExecutor("resilient-input", new() { AutoSendTurnToken = false })
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
|
||||
base.ConfigureProtocol(protocolBuilder).SendsMessage<string>();
|
||||
|
||||
protected override ValueTask TakeTurnAsync(
|
||||
List<ChatMessage> messages,
|
||||
IWorkflowContext context,
|
||||
bool? emitEvents,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string request = messages.LastOrDefault()?.Text
|
||||
?? throw new InvalidOperationException("The resilient workflow requires an input message.");
|
||||
string targetId = request.StartsWith("countdown:", StringComparison.Ordinal)
|
||||
? "resilient-countdown"
|
||||
: "resilient-work";
|
||||
return context.SendMessageAsync(
|
||||
request,
|
||||
targetId: targetId,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ResilientWorkExecutor()
|
||||
: Executor<string, string>("resilient-work")
|
||||
{
|
||||
public override async ValueTask<string> HandleAsync(
|
||||
string message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string[] parts = message.Split(':', 2, StringSplitOptions.TrimEntries);
|
||||
if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[1]))
|
||||
{
|
||||
throw new InvalidOperationException("Expected '<mode>:<token>'.");
|
||||
}
|
||||
|
||||
string mode = parts[0];
|
||||
string token = parts[1];
|
||||
|
||||
if (string.Equals(mode, "long", StringComparison.Ordinal))
|
||||
{
|
||||
int delaySeconds = GetLongRunningDelaySeconds();
|
||||
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), cancellationToken).ConfigureAwait(false);
|
||||
return $"LONG-RUN-COMPLETE:{token}";
|
||||
}
|
||||
|
||||
if (string.Equals(mode, "crash", StringComparison.Ordinal))
|
||||
{
|
||||
if (TryCreateCrashMarker(token, out string crashedProcessIncarnation))
|
||||
{
|
||||
Console.Out.Flush();
|
||||
Console.Error.Flush();
|
||||
Environment.Exit(70);
|
||||
throw new InvalidOperationException("Process termination did not stop execution.");
|
||||
}
|
||||
|
||||
if (string.Equals(crashedProcessIncarnation, s_processIncarnation, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("The crash recovery stage resumed in the original process.");
|
||||
}
|
||||
|
||||
return $"CRASH-RECOVERED:{token}:PROCESS-CHANGED";
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unknown resilient workflow mode '{mode}'.");
|
||||
}
|
||||
|
||||
private static int GetLongRunningDelaySeconds()
|
||||
{
|
||||
const int DefaultDelaySeconds = 20;
|
||||
string? value = Environment.GetEnvironmentVariable("IT_LONG_RUNNING_DELAY_SECONDS");
|
||||
return int.TryParse(value, out int seconds) && seconds > 0 ? seconds : DefaultDelaySeconds;
|
||||
}
|
||||
}
|
||||
|
||||
[SendsMessage(typeof(string))]
|
||||
[YieldsOutput(typeof(string))]
|
||||
private sealed class ResilientCountdownExecutor()
|
||||
: Executor<string>("resilient-countdown")
|
||||
{
|
||||
public override async ValueTask HandleAsync(
|
||||
string message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
CountdownState state = CountdownState.Parse(message);
|
||||
if (state.Current <= 0)
|
||||
{
|
||||
await context.SendMessageAsync(
|
||||
"Countdown complete.",
|
||||
targetId: "resilient-countdown-complete",
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(
|
||||
TimeSpan.FromMilliseconds(GetCountdownDelayMilliseconds()),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
await context.YieldOutputAsync(
|
||||
state.Current.ToString(CultureInfo.InvariantCulture),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
CountdownState next = state with { Current = state.Current - 1 };
|
||||
string targetId = state.Current == state.CrashAtValue
|
||||
? "resilient-countdown-crash"
|
||||
: "resilient-countdown";
|
||||
await context.SendMessageAsync(
|
||||
next.ToString(),
|
||||
targetId: targetId,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static int GetCountdownDelayMilliseconds()
|
||||
{
|
||||
const int DefaultDelayMilliseconds = 250;
|
||||
string? value = Environment.GetEnvironmentVariable(
|
||||
"IT_COUNTDOWN_DELAY_MILLISECONDS");
|
||||
return int.TryParse(
|
||||
value,
|
||||
NumberStyles.None,
|
||||
CultureInfo.InvariantCulture,
|
||||
out int milliseconds)
|
||||
&& milliseconds >= 0
|
||||
? milliseconds
|
||||
: DefaultDelayMilliseconds;
|
||||
}
|
||||
}
|
||||
|
||||
[SendsMessage(typeof(string))]
|
||||
private sealed class ResilientCountdownCrashExecutor()
|
||||
: Executor<string>("resilient-countdown-crash")
|
||||
{
|
||||
public override async ValueTask HandleAsync(
|
||||
string message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
CountdownState state = CountdownState.Parse(message);
|
||||
if (TryCreateCrashMarker(
|
||||
state.Token,
|
||||
out string crashedProcessIncarnation))
|
||||
{
|
||||
await Task.Delay(
|
||||
TimeSpan.FromSeconds(GetCountdownCrashDelaySeconds()),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
Console.Out.Flush();
|
||||
Console.Error.Flush();
|
||||
Environment.Exit(70);
|
||||
throw new InvalidOperationException(
|
||||
"Process termination did not stop execution.");
|
||||
}
|
||||
|
||||
if (string.Equals(
|
||||
crashedProcessIncarnation,
|
||||
s_processIncarnation,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The countdown resumed in the original process.");
|
||||
}
|
||||
|
||||
await context.SendMessageAsync(
|
||||
state.ToString(),
|
||||
targetId: "resilient-countdown",
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static int GetCountdownCrashDelaySeconds()
|
||||
{
|
||||
const int DefaultDelaySeconds = 5;
|
||||
string? value = Environment.GetEnvironmentVariable(
|
||||
"IT_COUNTDOWN_CRASH_DELAY_SECONDS");
|
||||
return int.TryParse(
|
||||
value,
|
||||
NumberStyles.None,
|
||||
CultureInfo.InvariantCulture,
|
||||
out int seconds)
|
||||
&& seconds >= 0
|
||||
? seconds
|
||||
: DefaultDelaySeconds;
|
||||
}
|
||||
}
|
||||
|
||||
[YieldsOutput(typeof(string))]
|
||||
private sealed class ResilientCountdownCompleteExecutor()
|
||||
: Executor<string>("resilient-countdown-complete")
|
||||
{
|
||||
public override ValueTask HandleAsync(
|
||||
string message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
context.YieldOutputAsync(message, cancellationToken);
|
||||
}
|
||||
|
||||
[YieldsOutput(typeof(string))]
|
||||
private sealed class ResilientOutputExecutor()
|
||||
: Executor<string>("resilient-output")
|
||||
{
|
||||
public override async ValueTask HandleAsync(
|
||||
string message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await context.YieldOutputAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryCreateCrashMarker(
|
||||
string token,
|
||||
out string crashedProcessIncarnation)
|
||||
{
|
||||
string home = Environment.GetEnvironmentVariable("HOME")
|
||||
?? throw new InvalidOperationException("HOME is not set.");
|
||||
string markerDirectory = Path.Combine(
|
||||
home,
|
||||
".foundry-hosting-it",
|
||||
"resilient-workflow");
|
||||
Directory.CreateDirectory(markerDirectory);
|
||||
|
||||
string markerName =
|
||||
Convert.ToHexString(
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(token)))
|
||||
+ ".crashed";
|
||||
string markerPath = Path.Combine(markerDirectory, markerName);
|
||||
|
||||
try
|
||||
{
|
||||
using FileStream marker = new(
|
||||
markerPath,
|
||||
FileMode.CreateNew,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
bufferSize: 1,
|
||||
FileOptions.WriteThrough);
|
||||
byte[] incarnation = Encoding.UTF8.GetBytes(
|
||||
s_processIncarnation);
|
||||
marker.Write(incarnation);
|
||||
marker.Flush(flushToDisk: true);
|
||||
crashedProcessIncarnation = s_processIncarnation;
|
||||
return true;
|
||||
}
|
||||
catch (IOException) when (File.Exists(markerPath))
|
||||
{
|
||||
crashedProcessIncarnation =
|
||||
File.ReadAllText(markerPath, Encoding.UTF8).Trim();
|
||||
if (string.IsNullOrWhiteSpace(crashedProcessIncarnation))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The crash marker does not contain a process incarnation.");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CountdownState(
|
||||
int Current,
|
||||
int CrashAtValue,
|
||||
string Token)
|
||||
{
|
||||
private const string InitialPrefix = "countdown";
|
||||
private const string StatePrefix = "countdown-state";
|
||||
|
||||
public static CountdownState Parse(string value)
|
||||
{
|
||||
string[] parts = value.Split(
|
||||
':',
|
||||
4,
|
||||
StringSplitOptions.TrimEntries);
|
||||
if (parts.Length != 4
|
||||
|| !int.TryParse(
|
||||
parts[1],
|
||||
NumberStyles.None,
|
||||
CultureInfo.InvariantCulture,
|
||||
out int first)
|
||||
|| !int.TryParse(
|
||||
parts[2],
|
||||
NumberStyles.None,
|
||||
CultureInfo.InvariantCulture,
|
||||
out int second)
|
||||
|| string.IsNullOrWhiteSpace(parts[3]))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Expected 'countdown:<target>:<crash-after-count>:<token>' " +
|
||||
"or a valid countdown state.");
|
||||
}
|
||||
|
||||
if (string.Equals(
|
||||
parts[0],
|
||||
InitialPrefix,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
if (first < 2 || second < 1 || second >= first)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Countdown target must be at least 2 and the crash count " +
|
||||
"must be between 1 and target minus 1.");
|
||||
}
|
||||
|
||||
return new(
|
||||
Current: first,
|
||||
CrashAtValue: first - second + 1,
|
||||
Token: parts[3]);
|
||||
}
|
||||
|
||||
if (string.Equals(
|
||||
parts[0],
|
||||
StatePrefix,
|
||||
StringComparison.Ordinal)
|
||||
&& first >= 0
|
||||
&& second > 0)
|
||||
{
|
||||
return new(
|
||||
Current: first,
|
||||
CrashAtValue: second,
|
||||
Token: parts[3]);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"The countdown state prefix or values are invalid.");
|
||||
}
|
||||
|
||||
public override string ToString() =>
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{StatePrefix}:{this.Current}:{this.CrashAtValue}:{this.Token}");
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.TestContainer;
|
||||
|
||||
internal sealed class SteerableLongRunningAgent : AIAgent
|
||||
{
|
||||
private int _activeRuns;
|
||||
private int _maxConcurrentRuns;
|
||||
|
||||
public override string? Name => "steerable-long-running-agent";
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var steeringSession = session as SteeringSession
|
||||
?? throw new InvalidOperationException("The steering agent requires a SteeringSession.");
|
||||
int activeRuns = Interlocked.Increment(ref this._activeRuns);
|
||||
UpdateMaximum(ref this._maxConcurrentRuns, activeRuns);
|
||||
|
||||
try
|
||||
{
|
||||
int sessionTurn = ++steeringSession.Turn;
|
||||
string input = string.Join(
|
||||
"\n",
|
||||
messages.Select(message => message.Text).Where(text => text is not null));
|
||||
string[] parts = input.Split(':', 2, StringSplitOptions.TrimEntries);
|
||||
if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[1]))
|
||||
{
|
||||
throw new InvalidOperationException("Expected '<mode>:<token>'.");
|
||||
}
|
||||
|
||||
string mode = parts[0];
|
||||
string token = parts[1];
|
||||
if (string.Equals(mode, "first", StringComparison.Ordinal))
|
||||
{
|
||||
yield return NewUpdate(
|
||||
$"FIRST-STARTED:{token}:SESSION-TURN-{sessionTurn}");
|
||||
|
||||
int delaySeconds = GetLongRunningDelaySeconds();
|
||||
await Task.Delay(
|
||||
TimeSpan.FromSeconds(delaySeconds),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
yield return NewUpdate(
|
||||
$"FIRST-NATURAL-COMPLETE:{token}:SESSION-TURN-{sessionTurn}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (string.Equals(mode, "steer", StringComparison.Ordinal))
|
||||
{
|
||||
yield return NewUpdate(
|
||||
$"STEERED-COMPLETE:{token}:SESSION-TURN-{sessionTurn}:" +
|
||||
$"MAX-CONCURRENCY-{this.MaxConcurrentRuns}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Unknown steerable long-running mode '{mode}'.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Decrement(ref this._activeRuns);
|
||||
}
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(new SteeringSession());
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var steeringSession = session as SteeringSession
|
||||
?? throw new InvalidOperationException("The steering agent requires a SteeringSession.");
|
||||
return new(JsonSerializer.SerializeToElement(
|
||||
new SerializedSession(steeringSession.Turn),
|
||||
jsonSerializerOptions));
|
||||
}
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
SerializedSession state = serializedState.Deserialize<SerializedSession>(
|
||||
jsonSerializerOptions)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not deserialize the steering session.");
|
||||
return new(new SteeringSession { Turn = state.Turn });
|
||||
}
|
||||
|
||||
private int MaxConcurrentRuns => Volatile.Read(ref this._maxConcurrentRuns);
|
||||
|
||||
private static AgentResponseUpdate NewUpdate(string text) =>
|
||||
new()
|
||||
{
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
Contents = [new TextContent(text)],
|
||||
};
|
||||
|
||||
private static int GetLongRunningDelaySeconds()
|
||||
{
|
||||
const int DefaultDelaySeconds = 30;
|
||||
string? value = Environment.GetEnvironmentVariable(
|
||||
"IT_STEERING_LONG_RUNNING_DELAY_SECONDS");
|
||||
return int.TryParse(value, out int seconds) && seconds > 0
|
||||
? seconds
|
||||
: DefaultDelaySeconds;
|
||||
}
|
||||
|
||||
private static void UpdateMaximum(ref int maximum, int candidate)
|
||||
{
|
||||
int current;
|
||||
do
|
||||
{
|
||||
current = Volatile.Read(ref maximum);
|
||||
if (candidate <= current)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
while (Interlocked.CompareExchange(ref maximum, candidate, current) != current);
|
||||
}
|
||||
|
||||
private sealed class SteeringSession : AgentSession
|
||||
{
|
||||
public int Turn { get; set; }
|
||||
}
|
||||
|
||||
private sealed record SerializedSession(int Turn);
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in
|
||||
/// <c>IT_SCENARIO=resilient-workflow</c> mode.
|
||||
/// </summary>
|
||||
public sealed class ResilientWorkflowHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "resilient-workflow";
|
||||
|
||||
protected override TimeSpan ProvisioningTimeout => TimeSpan.FromMinutes(8);
|
||||
|
||||
protected override void ConfigureEnvironment(IDictionary<string, string> environment)
|
||||
{
|
||||
environment["IT_LONG_RUNNING_DELAY_SECONDS"] = "20";
|
||||
environment["IT_COUNTDOWN_DELAY_MILLISECONDS"] = "250";
|
||||
environment["IT_COUNTDOWN_CRASH_DELAY_SECONDS"] = "5";
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in
|
||||
/// <c>IT_SCENARIO=steerable-long-running</c> mode.
|
||||
/// </summary>
|
||||
public sealed class SteerableLongRunningHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "steerable-long-running";
|
||||
|
||||
protected override TimeSpan ProvisioningTimeout => TimeSpan.FromMinutes(8);
|
||||
|
||||
protected override void ConfigureEnvironment(
|
||||
IDictionary<string, string> environment)
|
||||
{
|
||||
environment["IT_STEERING_LONG_RUNNING_DELAY_SECONDS"] = "30";
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,19 @@ The container scenario injects `USER-ID:<platform-user-key>` via
|
||||
`x-agent-user-id`). The caller credential must be allowed to delegate via
|
||||
`x-ms-user-identity` or those tests fail with HTTP 403.
|
||||
|
||||
### Resilience and steering scenarios
|
||||
|
||||
- `ResilientWorkflowHostedAgentTests` uses `IT_SCENARIO=resilient-workflow` to verify that a
|
||||
background MAF workflow continues without client traffic and that a different process resumes it
|
||||
after `Environment.Exit(70)`. Its countdown test receives `20` through `11`, ends the container
|
||||
process, reconnects with the sequence-aware MAF continuation token, and verifies the recovered
|
||||
accumulator contains exactly `20` through `1`. A third call uses the same agent and session with
|
||||
the same response ID but no sequence cursor, and verifies the complete 20-item replay.
|
||||
- `SteerableLongRunningHostedAgentTests` uses `IT_SCENARIO=steerable-long-running` to start a
|
||||
background MAF turn, wait for its first streamed update, submit a second input on the same
|
||||
conversation, assert `queued`, and verify that the persisted `AgentSession` advances to turn 2
|
||||
without concurrent MAF executions.
|
||||
|
||||
## Required environment variables
|
||||
|
||||
| Variable | Source | Purpose |
|
||||
@@ -61,7 +74,7 @@ The container scenario injects `USER-ID:<platform-user-key>` via
|
||||
Hosted agent invocation requires the agent's own managed identity to hold the
|
||||
`Azure AI User` role on the project scope. Because each agent's MI is created when the
|
||||
agent is first provisioned (and recycled on agent delete), the bootstrap creates the
|
||||
eleven stable scenario agents once and grants the role to each MI. The fixture then only
|
||||
stable scenario agents once and grants the role to each MI. The fixture then only
|
||||
manages versions under those existing agents, so the role grants survive across runs.
|
||||
|
||||
```powershell
|
||||
@@ -233,6 +246,7 @@ human-only operation; CI only adds and deletes versions under existing agents.
|
||||
| `AzureSearchRagHostedAgentFixture` | `azure-search-rag` | `it-azure-search-rag` | RAG against a real Azure AI Search index seeded with Contoso Outdoors documents; verifies the model cites the retrieved sources. |
|
||||
| `SessionFilesHostedAgentFixture` | `session-files` | `it-session-files` | End-to-end: upload via `AgentSessionFiles` (alpha) into a pinned `agent_session_id`, invoke the agent, assert it reads the file via the container's `ReadFile` tool. |
|
||||
| `AgentSkillsHostedAgentFixture` | `agent-skills` | `it-agent-skills` | Agent skills via `AgentSkillsProvider`: advertises two Contoso Outdoors skills (support-style, escalation-policy) in the system prompt, loads them on demand via `load_skill`, verifies canary tokens prove the skill was loaded. |
|
||||
| `ResilientWorkflowHostedAgentFixture` | `resilient-workflow` | `it-resilient-workflow` | Stored background workflow remains active without client traffic, completes after an intentional container process crash, and replays a complete 20-item countdown without a sequence cursor. |
|
||||
|
||||
The scenarios marked (placeholder) are already wired into the test container `Program.cs`,
|
||||
but their assertions stay skipped pending live validation and stabilization of the relevant
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001 // Experimental Responses API surfaces
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Live long-running and crash-recovery tests for resilient background Responses hosting.
|
||||
/// </summary>
|
||||
[Trait("Category", "FoundryHostedAgents")]
|
||||
public sealed class ResilientWorkflowHostedAgentTests(ResilientWorkflowHostedAgentFixture fixture)
|
||||
: IClassFixture<ResilientWorkflowHostedAgentFixture>
|
||||
{
|
||||
private static readonly TimeSpan s_completionTimeout = TimeSpan.FromMinutes(6);
|
||||
private readonly ResilientWorkflowHostedAgentFixture _fixture = fixture;
|
||||
|
||||
[Fact]
|
||||
public async Task BackgroundResponse_ContinuesWithoutClientConnectionAsync()
|
||||
{
|
||||
// Arrange
|
||||
string token = Guid.NewGuid().ToString("N");
|
||||
CreateResponseOptions options = CreateBackgroundRequest($"long:{token}");
|
||||
var responses = this._fixture.AgentOpenAIClient.GetProjectResponsesClient();
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
|
||||
// Act
|
||||
ResponseResult accepted = (await responses.CreateResponseAsync(options)).Value;
|
||||
TimeSpan acceptanceTime = stopwatch.Elapsed;
|
||||
|
||||
// Leave the response alone while its deterministic delay runs.
|
||||
await Task.Delay(TimeSpan.FromSeconds(25));
|
||||
ResponseWaitResult waitResult = await WaitForTerminalAsync(responses, accepted.Id, s_completionTimeout);
|
||||
|
||||
// Assert
|
||||
Assert.True(accepted.Status is ResponseStatus.Queued or ResponseStatus.InProgress);
|
||||
Assert.True(acceptanceTime < TimeSpan.FromSeconds(10), $"Background acceptance took {acceptanceTime}.");
|
||||
Assert.Equal(ResponseStatus.Completed, waitResult.Response.Status);
|
||||
Assert.Contains($"LONG-RUN-COMPLETE:{token}", waitResult.Response.GetOutputText(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackgroundResponse_ProcessCrash_RecoversAndCompletesAsync()
|
||||
{
|
||||
// Arrange
|
||||
string token = Guid.NewGuid().ToString("N");
|
||||
CreateResponseOptions options = CreateBackgroundRequest($"crash:{token}");
|
||||
var responses = this._fixture.AgentOpenAIClient.GetProjectResponsesClient();
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
|
||||
// Act
|
||||
ResponseResult accepted = (await responses.CreateResponseAsync(options)).Value;
|
||||
TimeSpan acceptanceTime = stopwatch.Elapsed;
|
||||
ResponseWaitResult waitResult = await WaitForTerminalAsync(responses, accepted.Id, s_completionTimeout);
|
||||
|
||||
// Assert: this token is emitted only after a new process observes the crash marker written
|
||||
// immediately before Environment.Exit.
|
||||
Assert.True(accepted.Status is ResponseStatus.Queued or ResponseStatus.InProgress);
|
||||
Assert.True(
|
||||
waitResult.SawSessionNotReady
|
||||
|| waitResult.SawResponseNotFound
|
||||
|| waitResult.LongestPollDuration > acceptanceTime,
|
||||
"Expected recovery to return transient HTTP 424/404 or take longer than background acceptance. " +
|
||||
$"Acceptance: {acceptanceTime}; longest poll: {waitResult.LongestPollDuration}.");
|
||||
Assert.Equal(ResponseStatus.Completed, waitResult.Response.Status);
|
||||
Assert.Contains(
|
||||
$"CRASH-RECOVERED:{token}:PROCESS-CHANGED",
|
||||
waitResult.Response.GetOutputText(),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackgroundCountdown_ProcessCrash_RecoversAndReplaysAllUpdatesAsync()
|
||||
{
|
||||
// Arrange
|
||||
const int Target = 20;
|
||||
const int CrashAfterCount = 10;
|
||||
string token = Guid.NewGuid().ToString("N");
|
||||
List<string> expected =
|
||||
[
|
||||
.. Enumerable.Range(1, Target)
|
||||
.Reverse()
|
||||
.Select(value => value.ToString(System.Globalization.CultureInfo.InvariantCulture)),
|
||||
"Countdown complete.",
|
||||
];
|
||||
AIAgent agent = this._fixture.Agent;
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
AgentRunOptions initialOptions = new() { AllowBackgroundResponses = true };
|
||||
using CancellationTokenSource timeoutSource = new(s_completionTimeout);
|
||||
|
||||
// Act
|
||||
StreamCapture before = await CaptureUntilDisconnectAsync(
|
||||
agent,
|
||||
session,
|
||||
$"countdown:{Target}:{CrashAfterCount}:{token}",
|
||||
initialOptions,
|
||||
"before",
|
||||
timeoutSource.Token);
|
||||
|
||||
ResponseContinuationToken continuationToken = before.ContinuationToken
|
||||
?? throw new InvalidOperationException(
|
||||
"The interrupted stream did not provide a continuation token.");
|
||||
AgentRunOptions recoveryOptions = new()
|
||||
{
|
||||
AllowBackgroundResponses = true,
|
||||
ContinuationToken = continuationToken,
|
||||
};
|
||||
StreamCapture recovered = await CaptureToCompletionWithRetryAsync(
|
||||
agent,
|
||||
session,
|
||||
recoveryOptions,
|
||||
"recovered",
|
||||
before.CompletedMessageIds,
|
||||
timeoutSource.Token);
|
||||
|
||||
List<string> recoveredCountdown = [.. before.Texts, .. recovered.Texts];
|
||||
string responseId = before.ResponseId
|
||||
?? recovered.ResponseId
|
||||
?? throw new InvalidOperationException(
|
||||
"The countdown stream did not provide a response ID.");
|
||||
AgentRunOptions replayOptions = new()
|
||||
{
|
||||
AllowBackgroundResponses = true,
|
||||
ContinuationToken = CreateReplayFromStartToken(responseId),
|
||||
};
|
||||
StreamCapture replayed = await CaptureToCompletionWithRetryAsync(
|
||||
agent,
|
||||
session,
|
||||
replayOptions,
|
||||
"replayed",
|
||||
existingMessageIds: null,
|
||||
timeoutSource.Token);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expected.Take(CrashAfterCount), before.Texts);
|
||||
Assert.Equal(expected, recoveredCountdown);
|
||||
Assert.Equal(Target, CountCountdownUpdates(recoveredCountdown));
|
||||
Assert.Equal(expected, replayed.Texts);
|
||||
Assert.Equal(Target, CountCountdownUpdates(replayed.Texts));
|
||||
}
|
||||
|
||||
private static CreateResponseOptions CreateBackgroundRequest(string input)
|
||||
{
|
||||
CreateResponseOptions options = new()
|
||||
{
|
||||
BackgroundModeEnabled = true,
|
||||
StoredOutputEnabled = true,
|
||||
};
|
||||
options.InputItems.Add(ResponseItem.CreateUserMessageItem(input));
|
||||
return options;
|
||||
}
|
||||
|
||||
private static async Task<StreamCapture> CaptureUntilDisconnectAsync(
|
||||
AIAgent agent,
|
||||
AgentSession session,
|
||||
string input,
|
||||
AgentRunOptions options,
|
||||
string phase,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
StreamCapture capture = new();
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(
|
||||
input,
|
||||
session,
|
||||
options,
|
||||
cancellationToken))
|
||||
{
|
||||
capture.Observe(update, phase);
|
||||
}
|
||||
}
|
||||
catch (ClientResultException exception)
|
||||
when (IsTransientRecoveryStatus(exception.Status))
|
||||
{
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
}
|
||||
|
||||
return capture;
|
||||
}
|
||||
|
||||
private static async Task<StreamCapture> CaptureToCompletionWithRetryAsync(
|
||||
AIAgent agent,
|
||||
AgentSession session,
|
||||
AgentRunOptions options,
|
||||
string phase,
|
||||
IEnumerable<string>? existingMessageIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
StreamCapture capture = new(existingMessageIds);
|
||||
|
||||
while (!capture.ResponseCompleted)
|
||||
{
|
||||
if (capture.ContinuationToken is not null)
|
||||
{
|
||||
options.ContinuationToken = capture.ContinuationToken;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(
|
||||
session,
|
||||
options,
|
||||
cancellationToken))
|
||||
{
|
||||
capture.Observe(update, phase);
|
||||
}
|
||||
}
|
||||
catch (ClientResultException exception)
|
||||
when (IsTransientRecoveryStatus(exception.Status))
|
||||
{
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
}
|
||||
|
||||
if (!capture.ResponseCompleted)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return capture;
|
||||
}
|
||||
|
||||
private static ResponseContinuationToken CreateReplayFromStartToken(
|
||||
string responseId)
|
||||
{
|
||||
ResponseContinuationToken innerToken =
|
||||
ResponseContinuationToken.FromBytes(
|
||||
JsonSerializer.SerializeToUtf8Bytes(
|
||||
new { responseId }));
|
||||
string serializedInnerToken = JsonSerializer.Serialize(
|
||||
innerToken,
|
||||
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(
|
||||
typeof(ResponseContinuationToken)));
|
||||
byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(
|
||||
new
|
||||
{
|
||||
type = "chatClientAgentContinuationToken",
|
||||
innerToken = serializedInnerToken,
|
||||
});
|
||||
return ResponseContinuationToken.FromBytes(bytes);
|
||||
}
|
||||
|
||||
private static bool IsTransientRecoveryStatus(int status) =>
|
||||
status is 404 or 424 or 500 or 502 or 503;
|
||||
|
||||
private static int CountCountdownUpdates(IEnumerable<string> texts) =>
|
||||
texts.Count(text => text != "Countdown complete.");
|
||||
|
||||
private static async Task<ResponseWaitResult> WaitForTerminalAsync(
|
||||
ResponsesClient responses,
|
||||
string responseId,
|
||||
TimeSpan timeout)
|
||||
{
|
||||
bool sawSessionNotReady = false;
|
||||
bool sawResponseNotFound = false;
|
||||
TimeSpan longestPollDuration = TimeSpan.Zero;
|
||||
var deadline = DateTimeOffset.UtcNow + timeout;
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
ResponseResult response;
|
||||
Stopwatch pollStopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
response = (await responses.GetResponseAsync(responseId)).Value;
|
||||
}
|
||||
catch (ClientResultException ex) when (ex.Status == 424)
|
||||
{
|
||||
longestPollDuration = Max(longestPollDuration, pollStopwatch.Elapsed);
|
||||
sawSessionNotReady = true;
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
continue;
|
||||
}
|
||||
catch (ClientResultException ex) when (ex.Status == 404)
|
||||
{
|
||||
longestPollDuration = Max(longestPollDuration, pollStopwatch.Elapsed);
|
||||
sawResponseNotFound = true;
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
continue;
|
||||
}
|
||||
|
||||
longestPollDuration = Max(longestPollDuration, pollStopwatch.Elapsed);
|
||||
if (response.Status is ResponseStatus.Completed)
|
||||
{
|
||||
return new(
|
||||
response,
|
||||
sawSessionNotReady,
|
||||
sawResponseNotFound,
|
||||
longestPollDuration);
|
||||
}
|
||||
|
||||
if (response.Status is ResponseStatus.Cancelled or ResponseStatus.Failed or ResponseStatus.Incomplete)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Response '{responseId}' terminated with status '{response.Status}': {response.Error?.Message}");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
throw new TimeoutException($"Response '{responseId}' did not complete within {timeout}.");
|
||||
|
||||
static TimeSpan Max(TimeSpan left, TimeSpan right) => left >= right ? left : right;
|
||||
}
|
||||
|
||||
private sealed record ResponseWaitResult(
|
||||
ResponseResult Response,
|
||||
bool SawSessionNotReady,
|
||||
bool SawResponseNotFound,
|
||||
TimeSpan LongestPollDuration);
|
||||
|
||||
private sealed class StreamCapture
|
||||
{
|
||||
private readonly HashSet<string> _completedMessageIds;
|
||||
|
||||
public StreamCapture(
|
||||
IEnumerable<string>? existingMessageIds = null)
|
||||
{
|
||||
this._completedMessageIds = new(
|
||||
existingMessageIds ?? [],
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public List<string> Texts { get; } = [];
|
||||
|
||||
public IReadOnlyCollection<string> CompletedMessageIds =>
|
||||
this._completedMessageIds;
|
||||
|
||||
public string? ResponseId { get; private set; }
|
||||
|
||||
public ResponseContinuationToken? ContinuationToken { get; private set; }
|
||||
|
||||
public bool ResponseCompleted { get; private set; }
|
||||
|
||||
public void Observe(AgentResponseUpdate update, string phase)
|
||||
{
|
||||
object? rawRepresentation =
|
||||
update.RawRepresentation is ChatResponseUpdate chatResponseUpdate
|
||||
? chatResponseUpdate.RawRepresentation
|
||||
: update.RawRepresentation;
|
||||
|
||||
if (update.ContinuationToken is { } continuationToken)
|
||||
{
|
||||
this.ContinuationToken = continuationToken;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(update.ResponseId))
|
||||
{
|
||||
this.ResponseId = update.ResponseId;
|
||||
}
|
||||
|
||||
if (rawRepresentation is StreamingResponseOutputItemDoneUpdate
|
||||
{
|
||||
Item: MessageResponseItem message
|
||||
}
|
||||
&& this._completedMessageIds.Add(message.Id))
|
||||
{
|
||||
this.AddMessage(message, phase);
|
||||
}
|
||||
|
||||
ResponseResult? responseSnapshot = rawRepresentation switch
|
||||
{
|
||||
StreamingResponseCreatedUpdate created => created.Response,
|
||||
StreamingResponseInProgressUpdate inProgress =>
|
||||
inProgress.Response,
|
||||
StreamingResponseCompletedUpdate completed =>
|
||||
completed.Response,
|
||||
_ => null,
|
||||
};
|
||||
if (responseSnapshot is not null)
|
||||
{
|
||||
foreach (MessageResponseItem snapshotMessage in
|
||||
responseSnapshot.OutputItems.OfType<MessageResponseItem>())
|
||||
{
|
||||
if (this._completedMessageIds.Add(snapshotMessage.Id))
|
||||
{
|
||||
this.AddMessage(snapshotMessage, phase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rawRepresentation is StreamingResponseCompletedUpdate)
|
||||
{
|
||||
this.ResponseCompleted = true;
|
||||
}
|
||||
else if (rawRepresentation is StreamingResponseFailedUpdate failed)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Response '{failed.Response.Id}' failed: " +
|
||||
failed.Response.Error?.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddMessage(
|
||||
MessageResponseItem message,
|
||||
string phase)
|
||||
{
|
||||
string text = string.Concat(
|
||||
message.Content
|
||||
.Where(content =>
|
||||
content.Kind is ResponseContentPartKind.OutputText)
|
||||
.Select(content => content.Text));
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
this.Texts.Add(text);
|
||||
Console.WriteLine($"{phase} > {text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001 // Experimental Responses API surfaces
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Live steering tests for an active long-running MAF turn.
|
||||
/// </summary>
|
||||
[Trait("Category", "FoundryHostedAgents")]
|
||||
public sealed class SteerableLongRunningHostedAgentTests(
|
||||
SteerableLongRunningHostedAgentFixture fixture)
|
||||
: IClassFixture<SteerableLongRunningHostedAgentFixture>
|
||||
{
|
||||
private static readonly TimeSpan s_completionTimeout = TimeSpan.FromMinutes(6);
|
||||
private readonly SteerableLongRunningHostedAgentFixture _fixture = fixture;
|
||||
|
||||
[Fact]
|
||||
public async Task ActiveTurn_QueuesSteeringThenRunsItOnTheSameSessionAsync()
|
||||
{
|
||||
// Arrange
|
||||
string token = Guid.NewGuid().ToString("N");
|
||||
string conversationId = await this._fixture.CreateConversationAsync();
|
||||
var responses = this._fixture.AgentOpenAIClient.GetProjectResponsesClient();
|
||||
|
||||
try
|
||||
{
|
||||
CreateResponseOptions firstOptions =
|
||||
CreateBackgroundRequest(conversationId, $"first:{token}");
|
||||
string firstResponseId = await StartStreamingAndWaitForOutputAsync(
|
||||
responses,
|
||||
firstOptions,
|
||||
$"FIRST-STARTED:{token}",
|
||||
s_completionTimeout);
|
||||
|
||||
// Act
|
||||
CreateResponseOptions steeringOptions =
|
||||
CreateBackgroundRequest(conversationId, $"steer:{token}");
|
||||
ResponseResult steering = (await responses.CreateResponseAsync(steeringOptions)).Value;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ResponseStatus.Queued, steering.Status);
|
||||
|
||||
ResponseResult firstCompleted =
|
||||
await WaitForTerminalAsync(
|
||||
responses,
|
||||
firstResponseId,
|
||||
s_completionTimeout);
|
||||
ResponseResult steeringCompleted =
|
||||
await WaitForTerminalAsync(responses, steering.Id, s_completionTimeout);
|
||||
|
||||
Assert.Equal(ResponseStatus.Completed, firstCompleted.Status);
|
||||
Assert.Equal(ResponseStatus.Completed, steeringCompleted.Status);
|
||||
Assert.Contains(
|
||||
$"STEERED-COMPLETE:{token}:SESSION-TURN-2:MAX-CONCURRENCY-1",
|
||||
steeringCompleted.GetOutputText(),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this._fixture.DeleteConversationAsync(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
private static CreateResponseOptions CreateBackgroundRequest(
|
||||
string conversationId,
|
||||
string input)
|
||||
{
|
||||
CreateResponseOptions options = new()
|
||||
{
|
||||
AgentConversationId = conversationId,
|
||||
BackgroundModeEnabled = true,
|
||||
StoredOutputEnabled = true,
|
||||
};
|
||||
options.InputItems.Add(ResponseItem.CreateUserMessageItem(input));
|
||||
return options;
|
||||
}
|
||||
|
||||
private static async Task<string> StartStreamingAndWaitForOutputAsync(
|
||||
ResponsesClient responses,
|
||||
CreateResponseOptions options,
|
||||
string expected,
|
||||
TimeSpan timeout)
|
||||
{
|
||||
using CancellationTokenSource timeoutSource = new(timeout);
|
||||
string? responseId = null;
|
||||
StringBuilder text = new();
|
||||
|
||||
await foreach (StreamingResponseUpdate update in responses
|
||||
.CreateResponseStreamingAsync(options, timeoutSource.Token)
|
||||
.WithCancellation(timeoutSource.Token))
|
||||
{
|
||||
switch (update)
|
||||
{
|
||||
case StreamingResponseCreatedUpdate created:
|
||||
responseId = created.Response.Id;
|
||||
break;
|
||||
|
||||
case StreamingResponseOutputTextDeltaUpdate delta:
|
||||
text.Append(delta.Delta);
|
||||
if (text.ToString().Contains(expected, StringComparison.Ordinal))
|
||||
{
|
||||
return responseId
|
||||
?? throw new InvalidOperationException(
|
||||
"The stream emitted text before response.created.");
|
||||
}
|
||||
break;
|
||||
|
||||
case StreamingResponseFailedUpdate failed:
|
||||
throw new InvalidOperationException(
|
||||
$"Response '{failed.Response.Id}' failed: " +
|
||||
failed.Response.Error?.Message);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"The response stream ended before emitting '{expected}'.");
|
||||
}
|
||||
|
||||
private static async Task<ResponseResult> WaitForTerminalAsync(
|
||||
ResponsesClient responses,
|
||||
string responseId,
|
||||
TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTimeOffset.UtcNow + timeout;
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
ResponseResult? response = await TryGetResponseAsync(responses, responseId);
|
||||
if (response?.Status is ResponseStatus.Completed)
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
if (response is not null)
|
||||
{
|
||||
ThrowIfTerminalFailure(responseId, response);
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
throw new TimeoutException(
|
||||
$"Response '{responseId}' did not complete within {timeout}.");
|
||||
}
|
||||
|
||||
private static async Task<ResponseResult?> TryGetResponseAsync(
|
||||
ResponsesClient responses,
|
||||
string responseId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (await responses.GetResponseAsync(responseId)).Value;
|
||||
}
|
||||
catch (ClientResultException ex) when (ex.Status is 404 or 424)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ThrowIfTerminalFailure(
|
||||
string responseId,
|
||||
ResponseResult response)
|
||||
{
|
||||
if (response.Status is ResponseStatus.Cancelled
|
||||
or ResponseStatus.Failed
|
||||
or ResponseStatus.Incomplete)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Response '{responseId}' terminated with status '{response.Status}': " +
|
||||
response.Error?.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,8 @@ $Scenarios = @(
|
||||
'session-files',
|
||||
'agent-skills',
|
||||
'user-identity',
|
||||
'resilient-workflow',
|
||||
'steerable-long-running',
|
||||
'unsupported-protocol'
|
||||
)
|
||||
|
||||
|
||||
@@ -83,13 +83,24 @@ $hashedDirs = @(
|
||||
$sourceFiles = @()
|
||||
foreach ($dir in $hashedDirs) {
|
||||
if (Test-Path $dir) {
|
||||
$sourceFiles += @(git -c core.quotepath=false ls-files -- $dir)
|
||||
$sourceFiles += @(git -c core.quotepath=false ls-files --cached --others --exclude-standard -- $dir)
|
||||
}
|
||||
}
|
||||
if ($sourceFiles.Count -eq 0) {
|
||||
throw "No tracked files found under any of: $($hashedDirs -join ', ')"
|
||||
throw "No source files found under any of: $($hashedDirs -join ', ')"
|
||||
}
|
||||
$fileHashes = git hash-object -- $sourceFiles
|
||||
|
||||
# Keep each git invocation below the Windows command-line length limit.
|
||||
$fileHashes = @()
|
||||
$maxHashBatchSize = 100
|
||||
for ($offset = 0; $offset -lt $sourceFiles.Count; $offset += $maxHashBatchSize) {
|
||||
$end = [Math]::Min($offset + $maxHashBatchSize - 1, $sourceFiles.Count - 1)
|
||||
$fileHashes += @(git hash-object -- $sourceFiles[$offset..$end])
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git hash-object failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
}
|
||||
|
||||
$shaInput = ($fileHashes -join "`n" | git hash-object --stdin).Trim()
|
||||
$tag = $shaInput.Substring(0, 12)
|
||||
$image = "$Registry/$Repository`:$tag"
|
||||
|
||||
+589
@@ -0,0 +1,589 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic tests for the resilient (crash-recovery) behavior of
|
||||
/// <see cref="AgentFrameworkResponseHandler"/>. They drive the handler with a fake agent that
|
||||
/// records the messages it receives and a fake session store, so recovery semantics can be asserted
|
||||
/// without a real model, a real process crash, or timing.
|
||||
/// </summary>
|
||||
public class AgentFrameworkResponseHandlerResilienceTests
|
||||
{
|
||||
private const string ResponseId = "resp_0000000000000000000000000000000000000000000000";
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_Recovery_WithoutPersistedSession_ReinjectsInputAsync()
|
||||
{
|
||||
// Arrange: recovery ran before the first AgentSession snapshot was persisted.
|
||||
var recording = new RecordingAgent();
|
||||
var handler = CreateHandler(recording, new InMemoryAgentSessionStore(), resilient: true);
|
||||
var request = NewBackgroundStoreRequest("original input");
|
||||
var context = CreateContext(isRecovery: true);
|
||||
|
||||
// Act
|
||||
await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: no session exists to resume, so recovery must restart from the original input.
|
||||
Assert.NotNull(recording.LastMessages);
|
||||
Assert.Contains(
|
||||
recording.LastMessages!,
|
||||
message => message.Text.Contains("original input", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_Recovery_WithPersistedSession_DoesNotReinjectInputAsync()
|
||||
{
|
||||
// Arrange: a prior lifetime persisted an AgentSession for this response.
|
||||
var recording = new RecordingAgent();
|
||||
var store = new AlwaysLoadedSessionStore();
|
||||
var handler = CreateHandler(recording, store, resilient: true);
|
||||
var request = NewBackgroundStoreRequest("original input");
|
||||
var context = CreateContext(isRecovery: true);
|
||||
|
||||
// Act
|
||||
await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: the restored session owns re-entry, so the original input is not duplicated.
|
||||
Assert.NotNull(recording.LastMessages);
|
||||
Assert.Empty(recording.LastMessages!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_FreshTurn_InjectsInputAsync()
|
||||
{
|
||||
// Arrange: the same request on a fresh (non-recovery) turn.
|
||||
var recording = new RecordingAgent();
|
||||
var handler = CreateHandler(recording, new InMemoryAgentSessionStore(), resilient: true);
|
||||
var request = NewBackgroundStoreRequest("original input");
|
||||
var context = CreateContext(isRecovery: false);
|
||||
|
||||
// Act
|
||||
await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: a fresh turn feeds the request input to the agent.
|
||||
Assert.NotNull(recording.LastMessages);
|
||||
Assert.Contains(recording.LastMessages!, m => m.Text.Contains("original input", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_ResilientTurn_MidStreamSaveFailure_StillCompletesAsync()
|
||||
{
|
||||
// Arrange: a store whose first save throws, mimicking the serialize race that can happen
|
||||
// when the incremental (mid-stream) save runs while the workflow is still advancing. The
|
||||
// later end-of-turn save succeeds.
|
||||
var store = new ThrowOnceSessionStore();
|
||||
var recording = new RecordingAgent();
|
||||
var handler = CreateHandler(recording, store, resilient: true);
|
||||
var request = NewBackgroundStoreRequest("hello");
|
||||
var context = CreateContext(isRecovery: false);
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: the failed incremental save was swallowed and the turn still reached a completed
|
||||
// terminal event (it did not escape as a handler failure that leaves the response stuck).
|
||||
Assert.True(store.SaveAttempts >= 1, "Expected at least one session save attempt.");
|
||||
Assert.Contains(events, e => e is ResponseCompletedEvent);
|
||||
Assert.DoesNotContain(events, e => e is ResponseFailedEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_Recovery_UsesAvailablePersistedResponseAsStreamSeedAsync()
|
||||
{
|
||||
// Arrange: AgentServer supplied a durable snapshot that happens to contain two output items.
|
||||
// This regular test agent has no workflow checkpoint metadata; the test verifies how the
|
||||
// handler consumes the available response snapshot on recovery.
|
||||
var persisted = new ResponseObject("resp_" + new string('0', 46), "test");
|
||||
persisted.Output.Add(NewMessageItem("prior_1", "prior item one"));
|
||||
persisted.Output.Add(NewMessageItem("prior_2", "prior item two"));
|
||||
|
||||
var recording = new RecordingAgent();
|
||||
var handler = CreateHandler(recording, new InMemoryAgentSessionStore(), resilient: true);
|
||||
var request = NewBackgroundStoreRequest("input");
|
||||
var context = CreateContext(isRecovery: true, persistedResponse: persisted);
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: new items start after the output watermark carried by the available snapshot. The
|
||||
// handler does not treat that watermark as the workflow checkpoint or re-emit seeded items.
|
||||
var addedIndexes = events.OfType<ResponseOutputItemAddedEvent>().Select(e => e.OutputIndex).ToList();
|
||||
Assert.NotEmpty(addedIndexes);
|
||||
Assert.All(addedIndexes, i => Assert.True(i >= 2, $"New output item index {i} collided with a seeded item (0 or 1)."));
|
||||
|
||||
// The final response retains the two items supplied by AgentServer and appends the newly
|
||||
// emitted item. This does not assert that normal workflow recovery produces such a snapshot.
|
||||
var completed = events.OfType<ResponseCompletedEvent>().Single();
|
||||
Assert.Equal(3, completed.Response.Output.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_NewWorkflowCheckpoint_DefaultStore_PersistsOneResponseCheckpointPerIdAsync()
|
||||
{
|
||||
// Arrange: the agent reports one workflow checkpoint twice, followed by a new checkpoint.
|
||||
var store = new CountingSessionStore();
|
||||
var handler = CreateHandler(
|
||||
new CheckpointUpdateAgent(
|
||||
await CreateWorkflowSessionAsync(),
|
||||
"checkpoint-1",
|
||||
"checkpoint-1",
|
||||
"checkpoint-2"),
|
||||
store,
|
||||
resilient: true);
|
||||
var request = NewBackgroundStoreRequest("start");
|
||||
request.Store = null;
|
||||
var context = CreateContext(isRecovery: false);
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: each distinct workflow checkpoint advances the durable response snapshot once.
|
||||
Assert.Equal(2, events.Count(e => e.GetType().Name == "ResponseCheckpointEvent"));
|
||||
Assert.Equal(3, store.SaveAttempts);
|
||||
|
||||
var completed = events.OfType<ResponseCompletedEvent>().Single();
|
||||
Assert.NotNull(completed.Response.Metadata);
|
||||
string metadataJson = completed.Response.Metadata.AdditionalProperties["_internal_metadata"];
|
||||
using JsonDocument metadata = JsonDocument.Parse(metadataJson);
|
||||
Assert.Equal(
|
||||
"checkpoint-2",
|
||||
metadata.RootElement.GetProperty("_last_checkpoint_id").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WorkflowCheckpoint_WhenSessionSaveFails_KeepsPriorResponseCheckpointAsync()
|
||||
{
|
||||
// Arrange: the workflow creates a checkpoint, but its matching AgentSession cannot be saved.
|
||||
var store = new ThrowOnceSessionStore();
|
||||
var handler = CreateHandler(
|
||||
new CheckpointUpdateAgent(
|
||||
await CreateWorkflowSessionAsync(),
|
||||
"checkpoint-1"),
|
||||
store,
|
||||
resilient: true);
|
||||
var request = NewBackgroundStoreRequest("start");
|
||||
var context = CreateContext(isRecovery: false);
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: the final save succeeds, but the response snapshot never claims the unsaved boundary.
|
||||
Assert.DoesNotContain(events, e => e.GetType().Name == "ResponseCheckpointEvent");
|
||||
Assert.Equal(2, store.SaveAttempts);
|
||||
|
||||
var completed = events.OfType<ResponseCompletedEvent>().Single();
|
||||
Assert.True(
|
||||
completed.Response.Metadata?.AdditionalProperties.ContainsKey("_internal_metadata") is not true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_ShutdownAfterAgentAdvanced_DoesNotSaveUnemittedSessionStateAsync()
|
||||
{
|
||||
// Arrange: the agent advances its session and returns an update after shutdown is visible.
|
||||
var store = new CountingSessionStore();
|
||||
var handler = CreateHandler(
|
||||
new SessionAdvancingAgent(),
|
||||
store,
|
||||
resilient: true);
|
||||
var request = NewBackgroundStoreRequest("input");
|
||||
var context = CreateContext(isRecovery: false, shutdownRequested: true);
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: only the lifecycle prefix was emitted, so advanced session state must not be saved.
|
||||
Assert.DoesNotContain(events, responseEvent => responseEvent is ResponseOutputItemDoneEvent);
|
||||
Assert.Equal(0, store.SaveAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ExistingThreeParameterSignature_IsPreserved()
|
||||
{
|
||||
// Act
|
||||
var constructor = typeof(AgentFrameworkResponseHandler).GetConstructor(
|
||||
[
|
||||
typeof(IServiceProvider),
|
||||
typeof(ILogger<AgentFrameworkResponseHandler>),
|
||||
typeof(FoundryToolboxService),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(constructor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_OptionsSignature_IsPreferredForActivatorUtilities()
|
||||
{
|
||||
// Act
|
||||
var constructor = typeof(AgentFrameworkResponseHandler).GetConstructor(
|
||||
[
|
||||
typeof(IServiceProvider),
|
||||
typeof(ILogger<AgentFrameworkResponseHandler>),
|
||||
typeof(IOptions<FoundryResponsesOptions>),
|
||||
typeof(FoundryToolboxService),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(constructor);
|
||||
Assert.NotNull(
|
||||
constructor.GetCustomAttribute<ActivatorUtilitiesConstructorAttribute>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddFoundryResponses_ResilientHandler_UsesConfiguredOptionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new RecordingAgent();
|
||||
var store = new CountingSessionStore();
|
||||
var services = new ServiceCollection();
|
||||
services.AddFoundryResponses(
|
||||
agent,
|
||||
store,
|
||||
options => options.ResilientBackground = true);
|
||||
services.AddLogging();
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(
|
||||
new FakeHostedSessionIsolationKeyProvider());
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
var handler = Assert.IsType<AgentFrameworkResponseHandler>(
|
||||
provider.GetRequiredService<ResponseHandler>());
|
||||
CreateResponse request = NewBackgroundStoreRequest("input");
|
||||
ResponseContext context = CreateContext(isRecovery: false);
|
||||
|
||||
// Act
|
||||
await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: one incremental save plus the final save proves the handler read the configured
|
||||
// resilience option rather than the compatibility constructor's default options.
|
||||
Assert.True(store.SaveAttempts >= 2);
|
||||
}
|
||||
|
||||
private static AgentFrameworkResponseHandler CreateHandler(AIAgent agent, AgentSessionStore store, bool resilient)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(store);
|
||||
services.AddSingleton(agent);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var options = Options.Create(new FoundryResponsesOptions { ResilientBackground = resilient });
|
||||
return new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance, toolboxService: null, foundryResponsesOptions: options);
|
||||
}
|
||||
|
||||
private static CreateResponse NewBackgroundStoreRequest(string text)
|
||||
{
|
||||
var request = new CreateResponse { Model = "test", Background = true, Store = true };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "message",
|
||||
id = "msg_in_1",
|
||||
status = "completed",
|
||||
role = "user",
|
||||
content = new[] { new { type = "input_text", text } }
|
||||
}
|
||||
});
|
||||
return request;
|
||||
}
|
||||
|
||||
private static ResponseContext CreateContext(
|
||||
bool isRecovery,
|
||||
ResponseObject? persistedResponse = null,
|
||||
bool shutdownRequested = false)
|
||||
{
|
||||
var mock = new Mock<ResponseContext>(ResponseId) { CallBase = true };
|
||||
mock.Setup(x => x.IsRecovery).Returns(isRecovery);
|
||||
mock.Setup(x => x.PersistedResponse).Returns(persistedResponse);
|
||||
mock.Setup(x => x.ExitForRecoveryAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
mock.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mock.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<Item>());
|
||||
if (shutdownRequested)
|
||||
{
|
||||
mock.Object.IsShutdownRequested = true;
|
||||
}
|
||||
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
private static OutputItemMessage NewMessageItem(string id, string text) =>
|
||||
new(
|
||||
id: id,
|
||||
role: MessageRole.Assistant,
|
||||
content: [new MessageContentOutputTextContent(text, Array.Empty<Annotation>(), Array.Empty<LogProb>())],
|
||||
status: MessageStatus.Completed);
|
||||
|
||||
private static async Task<List<ResponseStreamEvent>> CollectEventsAsync(
|
||||
AgentFrameworkResponseHandler handler,
|
||||
CreateResponse request,
|
||||
ResponseContext context)
|
||||
{
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in handler.CreateAsync(request, context, CancellationToken.None))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private static async Task<AgentSession> CreateWorkflowSessionAsync()
|
||||
{
|
||||
AIAgent workflowAgent = AgentWorkflowBuilder
|
||||
.BuildSequential(
|
||||
"checkpoint-session-workflow",
|
||||
new RecordingAgent())
|
||||
.AsAIAgent(
|
||||
id: "checkpoint-session-agent",
|
||||
name: "Checkpoint Session Agent");
|
||||
return await workflowAgent.CreateSessionAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A fake agent that records the messages passed to each run so a test can assert exactly what
|
||||
/// the handler fed it (for example, that recovery injected nothing).
|
||||
/// </summary>
|
||||
private sealed class RecordingAgent : AIAgent
|
||||
{
|
||||
public IReadOnlyList<ChatMessage>? LastMessages { get; private set; }
|
||||
|
||||
protected override string? IdCore => "recording-agent";
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.LastMessages = messages.ToList();
|
||||
yield return new AgentResponseUpdate
|
||||
{
|
||||
MessageId = "msg_rec_1",
|
||||
Contents = [new MeaiTextContent("recorded")]
|
||||
};
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
|
||||
new(new RecordingSession());
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(JsonSerializer.SerializeToElement(new { }, jsonSerializerOptions));
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(new RecordingSession());
|
||||
|
||||
private sealed class RecordingSession : AgentSession
|
||||
{
|
||||
public RecordingSession()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A fake session store whose first <see cref="SaveSessionAsync"/> throws (mimicking the
|
||||
/// serialize race), then succeeds, while loads always create a fresh session.
|
||||
/// </summary>
|
||||
private sealed class ThrowOnceSessionStore : AgentSessionStore
|
||||
{
|
||||
private int _saveAttempts;
|
||||
|
||||
public int SaveAttempts => this._saveAttempts;
|
||||
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, string? userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var attempt = Interlocked.Increment(ref this._saveAttempts);
|
||||
if (attempt == 1)
|
||||
{
|
||||
throw new InvalidOperationException("Collection was modified; enumeration operation may not execute.");
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
public override async ValueTask<AgentSession?> GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default) =>
|
||||
await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private sealed class CountingSessionStore : AgentSessionStore
|
||||
{
|
||||
private int _saveAttempts;
|
||||
|
||||
public int SaveAttempts => this._saveAttempts;
|
||||
|
||||
public override ValueTask SaveSessionAsync(
|
||||
AIAgent agent,
|
||||
string conversationId,
|
||||
AgentSession session,
|
||||
string? userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref this._saveAttempts);
|
||||
return default;
|
||||
}
|
||||
|
||||
public override ValueTask<AgentSession?> GetSessionAsync(
|
||||
AIAgent agent,
|
||||
string conversationId,
|
||||
string? userId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new((AgentSession?)null);
|
||||
}
|
||||
|
||||
private sealed class AlwaysLoadedSessionStore : AgentSessionStore
|
||||
{
|
||||
public override ValueTask SaveSessionAsync(
|
||||
AIAgent agent,
|
||||
string conversationId,
|
||||
AgentSession session,
|
||||
string? userId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
default;
|
||||
|
||||
public override async ValueTask<AgentSession?> GetSessionAsync(
|
||||
AIAgent agent,
|
||||
string conversationId,
|
||||
string? userId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private sealed class SessionAdvancingAgent : AIAgent
|
||||
{
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var advancingSession = Assert.IsType<AdvancingSession>(session);
|
||||
advancingSession.Phase = 1;
|
||||
yield return new AgentResponseUpdate
|
||||
{
|
||||
MessageId = "msg_shutdown_1",
|
||||
Contents = [new MeaiTextContent("not emitted")]
|
||||
};
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(new AdvancingSession());
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var advancingSession = Assert.IsType<AdvancingSession>(session);
|
||||
return new(JsonSerializer.SerializeToElement(
|
||||
new { advancingSession.Phase },
|
||||
jsonSerializerOptions));
|
||||
}
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(new AdvancingSession
|
||||
{
|
||||
Phase = serializedState.GetProperty("Phase").GetInt32(),
|
||||
});
|
||||
|
||||
private sealed class AdvancingSession : AgentSession
|
||||
{
|
||||
public int Phase { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CheckpointUpdateAgent(
|
||||
AgentSession workflowSession,
|
||||
params string[] checkpointIds) : AIAgent
|
||||
{
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var step = 0;
|
||||
foreach (string checkpointId in checkpointIds)
|
||||
{
|
||||
var checkpoint = new CheckpointInfo("workflow-session", checkpointId);
|
||||
var completion = new SuperStepCompletionInfo([]) { Checkpoint = checkpoint };
|
||||
yield return new AgentResponseUpdate
|
||||
{
|
||||
RawRepresentation = new SuperStepCompletedEvent(step++, completion),
|
||||
};
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(workflowSession);
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(JsonSerializer.SerializeToElement(new { }, jsonSerializerOptions));
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(workflowSession);
|
||||
}
|
||||
}
|
||||
+19
-3
@@ -11,6 +11,7 @@ using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
@@ -161,7 +162,10 @@ public class AgentFrameworkResponseHandlerWorkflowTests
|
||||
}
|
||||
|
||||
private static (AgentFrameworkResponseHandler handler, CreateResponse request, ResponseContext context)
|
||||
CreateHandlerWithAgent(AIAgent agent, string userMessage)
|
||||
CreateHandlerWithAgent(
|
||||
AIAgent agent,
|
||||
string userMessage,
|
||||
bool resilient = false)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
@@ -170,8 +174,20 @@ public class AgentFrameworkResponseHandlerWorkflowTests
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider>(new FakeHostedSessionIsolationKeyProvider());
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
var handler = new AgentFrameworkResponseHandler(
|
||||
sp,
|
||||
NullLogger<AgentFrameworkResponseHandler>.Instance,
|
||||
Options.Create(
|
||||
new FoundryResponsesOptions
|
||||
{
|
||||
ResilientBackground = resilient,
|
||||
}));
|
||||
var request = new CreateResponse
|
||||
{
|
||||
Model = "test",
|
||||
Background = resilient,
|
||||
Store = resilient,
|
||||
};
|
||||
request.Input = CreateUserInput(userMessage);
|
||||
var mockContext = CreateMockContext();
|
||||
|
||||
|
||||
+62
-2
@@ -8,6 +8,7 @@ using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Core.Storage;
|
||||
using Azure.Core;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -26,6 +27,59 @@ public sealed class FoundryJsonCheckpointStoreTests
|
||||
Assert.Equal(FoundryJsonCheckpointStore.DefaultStoreName, store.StoreName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultMaxIndexUpdateAttempts_IsEight()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal(8, FoundryJsonCheckpointStore.DefaultMaxIndexUpdateAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_MaxIndexUpdateAttemptsLessThanOne_Throws()
|
||||
{
|
||||
// Act
|
||||
var exception = Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => new FoundryJsonCheckpointStore(maxIndexUpdateAttempts: 0));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("maxIndexUpdateAttempts", exception.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ExistingFiveParameterSignature_IsPreserved()
|
||||
{
|
||||
// Act
|
||||
var constructor = typeof(FoundryJsonCheckpointStore).GetConstructor(
|
||||
[
|
||||
typeof(Uri),
|
||||
typeof(TokenCredential),
|
||||
typeof(string),
|
||||
typeof(int),
|
||||
typeof(ILoggerFactory),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(constructor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCheckpointAsync_CustomMaxIndexUpdateAttempts_LimitsRetriesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var backing = new FakeCheckpointStateStore();
|
||||
var store = NewStore(backing, maxIndexUpdateAttempts: 2);
|
||||
await store.CreateCheckpointAsync("session-1", Json("{\"step\":1}"));
|
||||
backing.FailNextIndexWrites = 2;
|
||||
|
||||
// Act
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await store.CreateCheckpointAsync("session-1", Json("{\"step\":2}")));
|
||||
|
||||
// Assert
|
||||
Assert.Contains("after 2 attempts", exception.Message, StringComparison.Ordinal);
|
||||
Assert.Equal(0, backing.FailNextIndexWrites);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCheckpointAsync_ThenRetrieveCheckpointAsync_RoundTripsAsync()
|
||||
{
|
||||
@@ -381,8 +435,14 @@ public sealed class FoundryJsonCheckpointStoreTests
|
||||
Assert.NotEqual(first, second);
|
||||
}
|
||||
|
||||
private static FoundryJsonCheckpointStore NewStore(FoundryStateStore backing, ILoggerFactory? loggerFactory = null)
|
||||
=> new(_ => Task.FromResult(backing), loggerFactory: loggerFactory);
|
||||
private static FoundryJsonCheckpointStore NewStore(
|
||||
FoundryStateStore backing,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
int maxIndexUpdateAttempts = FoundryJsonCheckpointStore.DefaultMaxIndexUpdateAttempts)
|
||||
=> new(
|
||||
_ => Task.FromResult(backing),
|
||||
loggerFactory: loggerFactory,
|
||||
maxIndexUpdateAttempts: maxIndexUpdateAttempts);
|
||||
|
||||
private static JsonElement Json(string json)
|
||||
{
|
||||
|
||||
+649
@@ -0,0 +1,649 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
[Collection(FoundryStateStoreLocalFallbackCollectionDefinition.CollectionName)]
|
||||
public sealed class ResilientTwoLifetimeIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StoppedHost_RecoversMafAgentFromPersistedSessionAsync()
|
||||
{
|
||||
// Arrange
|
||||
string stateRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"maf-recovery-{Guid.NewGuid():N}");
|
||||
string? previousStateRoot =
|
||||
Environment.GetEnvironmentVariable("AGENTSERVER_STATE_ROOT");
|
||||
string? previousHostingEnvironment =
|
||||
Environment.GetEnvironmentVariable("FOUNDRY_HOSTING_ENVIRONMENT");
|
||||
var coordinator = new RecoveryCoordinator();
|
||||
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("AGENTSERVER_STATE_ROOT", stateRoot);
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_HOSTING_ENVIRONMENT", null);
|
||||
|
||||
string conversationId = $"conv_{Guid.NewGuid():N}";
|
||||
string responseId;
|
||||
|
||||
WebApplication firstHost = await StartServerAsync(
|
||||
new ResumableAgent(coordinator),
|
||||
new PhaseObservingSessionStore(
|
||||
new FoundryAgentSessionStore(),
|
||||
coordinator));
|
||||
try
|
||||
{
|
||||
using HttpClient firstClient = GetClient(firstHost);
|
||||
responseId = await StartBackgroundResponseAsync(
|
||||
firstClient,
|
||||
conversationId);
|
||||
try
|
||||
{
|
||||
await coordinator.PhasePersisted.Task.WaitAsync(
|
||||
TimeSpan.FromSeconds(15));
|
||||
}
|
||||
catch (TimeoutException ex)
|
||||
{
|
||||
throw new TimeoutException(
|
||||
"Phase 1 was not observed in the persisted session. States: " +
|
||||
string.Join(Environment.NewLine, coordinator.SerializedStates),
|
||||
ex);
|
||||
}
|
||||
|
||||
using CancellationTokenSource stopTimeout =
|
||||
new(TimeSpan.FromSeconds(15));
|
||||
await firstHost.StopAsync(stopTimeout.Token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await firstHost.DisposeAsync();
|
||||
}
|
||||
|
||||
// Act
|
||||
await using WebApplication secondHost = await StartServerAsync(
|
||||
new ResumableAgent(coordinator),
|
||||
new FoundryAgentSessionStore());
|
||||
using HttpClient secondClient = GetClient(secondHost);
|
||||
JsonElement completed = await WaitForTerminalAsync(
|
||||
secondClient,
|
||||
responseId,
|
||||
TimeSpan.FromSeconds(20));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("completed", completed.GetProperty("status").GetString());
|
||||
Assert.Contains(
|
||||
"RECOVERED-COMPLETE",
|
||||
GetOutputText(completed),
|
||||
StringComparison.Ordinal);
|
||||
Assert.Equal(1, coordinator.FreshRuns);
|
||||
Assert.Equal(1, coordinator.RecoveryRuns);
|
||||
Assert.Empty(coordinator.RecoveryMessages);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(
|
||||
"AGENTSERVER_STATE_ROOT",
|
||||
previousStateRoot);
|
||||
Environment.SetEnvironmentVariable(
|
||||
"FOUNDRY_HOSTING_ENVIRONMENT",
|
||||
previousHostingEnvironment);
|
||||
|
||||
if (Directory.Exists(stateRoot))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(stateRoot, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoppedHost_RecoversWorkflowWithCompleteOrderedOutputAsync()
|
||||
{
|
||||
// Arrange
|
||||
string stateRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"maf-workflow-recovery-{Guid.NewGuid():N}");
|
||||
string checkpointRoot = Path.Combine(stateRoot, "workflow-checkpoints");
|
||||
string? previousStateRoot =
|
||||
Environment.GetEnvironmentVariable("AGENTSERVER_STATE_ROOT");
|
||||
string? previousHostingEnvironment =
|
||||
Environment.GetEnvironmentVariable("FOUNDRY_HOSTING_ENVIRONMENT");
|
||||
var coordinator = new CountdownRecoveryCoordinator(target: 6, blockAt: 3);
|
||||
string sessionStoreName = $"agent-framework/sessions-{Guid.NewGuid():N}";
|
||||
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("AGENTSERVER_STATE_ROOT", stateRoot);
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_HOSTING_ENVIRONMENT", null);
|
||||
|
||||
string conversationId = $"conv_{Guid.NewGuid():N}";
|
||||
string responseId;
|
||||
|
||||
using (var checkpointStore = new FileSystemJsonCheckpointStore(
|
||||
Directory.CreateDirectory(checkpointRoot)))
|
||||
{
|
||||
WebApplication firstHost = await StartServerAsync(
|
||||
BuildCountdownWorkflowAgent(coordinator, checkpointStore),
|
||||
new FoundryAgentSessionStore(storeName: sessionStoreName));
|
||||
try
|
||||
{
|
||||
using HttpClient firstClient = GetClient(firstHost);
|
||||
responseId = await StartBackgroundResponseAsync(
|
||||
firstClient,
|
||||
conversationId,
|
||||
agentName: "countdown-workflow",
|
||||
input: "Count down from 6");
|
||||
await coordinator.Blocked.Task.WaitAsync(TimeSpan.FromSeconds(15));
|
||||
await WaitForResponseProgressAsync(
|
||||
firstClient,
|
||||
responseId,
|
||||
["6", "5", "4"],
|
||||
minimumOutputItems: 12,
|
||||
timeout: TimeSpan.FromSeconds(15));
|
||||
|
||||
using CancellationTokenSource stopTimeout =
|
||||
new(TimeSpan.FromSeconds(15));
|
||||
await firstHost.StopAsync(stopTimeout.Token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await firstHost.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
JsonElement persisted = ReadPersistedResponse(stateRoot, responseId);
|
||||
Assert.Equal(["6", "5", "4"], GetOutputTexts(persisted));
|
||||
Assert.True(
|
||||
persisted.TryGetProperty("metadata", out JsonElement metadata)
|
||||
&& metadata.TryGetProperty("_internal_metadata", out _),
|
||||
persisted.GetRawText());
|
||||
|
||||
// Act
|
||||
using var recoveryCheckpointStore = new FileSystemJsonCheckpointStore(
|
||||
Directory.CreateDirectory(checkpointRoot));
|
||||
await using WebApplication secondHost = await StartServerAsync(
|
||||
BuildCountdownWorkflowAgent(coordinator, recoveryCheckpointStore),
|
||||
new FoundryAgentSessionStore(storeName: sessionStoreName));
|
||||
using HttpClient secondClient = GetClient(secondHost);
|
||||
JsonElement completed = await WaitForTerminalAsync(
|
||||
secondClient,
|
||||
responseId,
|
||||
TimeSpan.FromSeconds(20));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("completed", completed.GetProperty("status").GetString());
|
||||
Assert.Equal(
|
||||
["6", "5", "4", "3", "2", "1", "Countdown complete."],
|
||||
GetOutputTexts(completed));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(
|
||||
"AGENTSERVER_STATE_ROOT",
|
||||
previousStateRoot);
|
||||
Environment.SetEnvironmentVariable(
|
||||
"FOUNDRY_HOSTING_ENVIRONMENT",
|
||||
previousHostingEnvironment);
|
||||
|
||||
if (Directory.Exists(stateRoot))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(stateRoot, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<WebApplication> StartServerAsync(
|
||||
AIAgent agent,
|
||||
AgentSessionStore sessionStore)
|
||||
{
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
builder.Services.AddFoundryResponses(
|
||||
agent,
|
||||
sessionStore,
|
||||
options => options.ResilientBackground = true);
|
||||
builder.Services.AddSingleton<HostedSessionIsolationKeyProvider>(
|
||||
new FakeHostedSessionIsolationKeyProvider());
|
||||
builder.Services.AddLogging();
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
await app.StartAsync();
|
||||
return app;
|
||||
}
|
||||
|
||||
private static HttpClient GetClient(WebApplication app) =>
|
||||
(app.Services.GetRequiredService<IServer>() as TestServer
|
||||
?? throw new InvalidOperationException("TestServer not found."))
|
||||
.CreateClient();
|
||||
|
||||
private static async Task<string> StartBackgroundResponseAsync(
|
||||
HttpClient client,
|
||||
string conversationId,
|
||||
string agentName = "resumable-agent",
|
||||
string input = "start durable work")
|
||||
{
|
||||
string body = JsonSerializer.Serialize(new
|
||||
{
|
||||
model = agentName,
|
||||
input,
|
||||
store = true,
|
||||
background = true,
|
||||
conversation = conversationId,
|
||||
});
|
||||
using HttpResponseMessage response = await client.PostAsync(
|
||||
new Uri("/responses", UriKind.Relative),
|
||||
new StringContent(body, Encoding.UTF8, "application/json"));
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(
|
||||
await response.Content.ReadAsStringAsync());
|
||||
return document.RootElement.GetProperty("id").GetString()
|
||||
?? throw new InvalidOperationException(
|
||||
"The background response did not contain an id.");
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> WaitForTerminalAsync(
|
||||
HttpClient client,
|
||||
string responseId,
|
||||
TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTimeOffset.UtcNow + timeout;
|
||||
string last = "(none)";
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
using HttpResponseMessage response = await client.GetAsync(
|
||||
new Uri($"/responses/{responseId}", UriKind.Relative));
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
last = $"{(int)response.StatusCode} {body}";
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(body);
|
||||
JsonElement root = document.RootElement;
|
||||
string? status = root.GetProperty("status").GetString();
|
||||
if (status == "completed")
|
||||
{
|
||||
return root.Clone();
|
||||
}
|
||||
|
||||
if (status is "failed" or "cancelled" or "incomplete")
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Response '{responseId}' terminated with status '{status}': {body}");
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(50));
|
||||
}
|
||||
|
||||
throw new TimeoutException(
|
||||
$"Response '{responseId}' did not complete. Last response: {last}");
|
||||
}
|
||||
|
||||
private static async Task WaitForResponseProgressAsync(
|
||||
HttpClient client,
|
||||
string responseId,
|
||||
IReadOnlyList<string> expected,
|
||||
int minimumOutputItems,
|
||||
TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTimeOffset.UtcNow + timeout;
|
||||
List<string> last = [];
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
using HttpResponseMessage response = await client.GetAsync(
|
||||
new Uri($"/responses/{responseId}", UriKind.Relative));
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(
|
||||
await response.Content.ReadAsStringAsync());
|
||||
JsonElement root = document.RootElement;
|
||||
last = GetOutputTexts(root);
|
||||
if (last.Count == expected.Count
|
||||
&& last.SequenceEqual(expected)
|
||||
&& root.GetProperty("output").GetArrayLength() >= minimumOutputItems)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(25));
|
||||
}
|
||||
|
||||
throw new TimeoutException(
|
||||
$"Response '{responseId}' did not reach the expected checkpointed output. " +
|
||||
$"Expected: {string.Join(", ", expected)}. Last: {string.Join(", ", last)}.");
|
||||
}
|
||||
|
||||
private static JsonElement ReadPersistedResponse(
|
||||
string stateRoot,
|
||||
string responseId)
|
||||
{
|
||||
string path = Path.Combine(
|
||||
stateRoot,
|
||||
"responses",
|
||||
"envelopes",
|
||||
$"{responseId}.json");
|
||||
using JsonDocument document = JsonDocument.Parse(File.ReadAllBytes(path));
|
||||
return document.RootElement.GetProperty("envelope").Clone();
|
||||
}
|
||||
|
||||
private static string GetOutputText(JsonElement response)
|
||||
{
|
||||
StringBuilder text = new();
|
||||
foreach (JsonElement item in response.GetProperty("output").EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetProperty("content", out JsonElement content))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (JsonElement part in content.EnumerateArray())
|
||||
{
|
||||
if (part.TryGetProperty("text", out JsonElement value))
|
||||
{
|
||||
text.Append(value.GetString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
private static List<string> GetOutputTexts(JsonElement response)
|
||||
{
|
||||
List<string> texts = [];
|
||||
foreach (JsonElement item in response.GetProperty("output").EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetProperty("content", out JsonElement content))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (JsonElement part in content.EnumerateArray())
|
||||
{
|
||||
if (part.TryGetProperty("text", out JsonElement value)
|
||||
&& value.GetString() is { } text)
|
||||
{
|
||||
texts.Add(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return texts;
|
||||
}
|
||||
|
||||
private static AIAgent BuildCountdownWorkflowAgent(
|
||||
CountdownRecoveryCoordinator coordinator,
|
||||
FileSystemJsonCheckpointStore checkpointStore)
|
||||
{
|
||||
var start = new CountdownStartExecutor(coordinator.Target);
|
||||
var countdown = new CountdownExecutor(coordinator);
|
||||
var complete = new CountdownCompleteExecutor();
|
||||
Workflow workflow = new WorkflowBuilder(start)
|
||||
.AddEdge(start, countdown)
|
||||
.AddEdge(countdown, countdown)
|
||||
.AddEdge(countdown, complete)
|
||||
.WithOutputFrom(countdown, complete)
|
||||
.Build();
|
||||
|
||||
return workflow.AsAIAgent(
|
||||
id: "countdown-workflow",
|
||||
name: "countdown-workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread.WithCheckpointing(
|
||||
CheckpointManager.CreateJson(checkpointStore)),
|
||||
includeExceptionDetails: true,
|
||||
includeWorkflowOutputsInResponse: true);
|
||||
}
|
||||
|
||||
[SendsMessage(typeof(int))]
|
||||
private sealed class CountdownStartExecutor(int target) : ChatProtocolExecutor(
|
||||
"start",
|
||||
new ChatProtocolExecutorOptions { AutoSendTurnToken = false })
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
|
||||
base.ConfigureProtocol(protocolBuilder).SendsMessage<int>();
|
||||
|
||||
protected override ValueTask TakeTurnAsync(
|
||||
List<ChatMessage> messages,
|
||||
IWorkflowContext context,
|
||||
bool? emitEvents,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
context.SendMessageAsync(target, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
[SendsMessage(typeof(int))]
|
||||
[SendsMessage(typeof(string))]
|
||||
[YieldsOutput(typeof(string))]
|
||||
private sealed class CountdownExecutor(CountdownRecoveryCoordinator coordinator) : Executor<int>("countdown")
|
||||
{
|
||||
public override async ValueTask HandleAsync(
|
||||
int message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (message <= 0)
|
||||
{
|
||||
await context.SendMessageAsync(
|
||||
"Countdown complete.",
|
||||
targetId: "complete",
|
||||
cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (coordinator.ShouldBlock(message))
|
||||
{
|
||||
coordinator.Blocked.TrySetResult();
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
}
|
||||
|
||||
await context.YieldOutputAsync(message.ToString(), cancellationToken);
|
||||
await context.SendMessageAsync(
|
||||
message - 1,
|
||||
targetId: "countdown",
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
[YieldsOutput(typeof(string))]
|
||||
private sealed class CountdownCompleteExecutor() : Executor<string>("complete")
|
||||
{
|
||||
public override ValueTask HandleAsync(
|
||||
string message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
context.YieldOutputAsync(message, cancellationToken);
|
||||
}
|
||||
|
||||
private sealed class ResumableAgent(RecoveryCoordinator coordinator) : AIAgent
|
||||
{
|
||||
protected override string? IdCore => "resumable-agent";
|
||||
|
||||
public override string? Name => "resumable-agent";
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate>
|
||||
RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var resumableSession = session as ResumableSession
|
||||
?? throw new InvalidOperationException(
|
||||
"The resumable agent requires a ResumableSession.");
|
||||
string[] input = messages
|
||||
.Select(message => message.Text)
|
||||
.Where(text => text is not null)
|
||||
.ToArray()!;
|
||||
|
||||
if (resumableSession.Phase == 0)
|
||||
{
|
||||
Interlocked.Increment(ref coordinator.FreshRuns);
|
||||
resumableSession.Phase = 1;
|
||||
yield return NewUpdate("PHASE-1-COMPLETE");
|
||||
yield return NewUpdate("PHASE-2-STARTED");
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
yield break;
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref coordinator.RecoveryRuns);
|
||||
coordinator.RecoveryMessages = input;
|
||||
resumableSession.Phase = 2;
|
||||
yield return NewUpdate("RECOVERED-COMPLETE");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(new ResumableSession());
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var resumableSession = session as ResumableSession
|
||||
?? throw new InvalidOperationException(
|
||||
"The resumable agent requires a ResumableSession.");
|
||||
return new(JsonSerializer.SerializeToElement(
|
||||
new SerializedSession(resumableSession.Phase),
|
||||
jsonSerializerOptions));
|
||||
}
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
SerializedSession state = serializedState.Deserialize<SerializedSession>(
|
||||
jsonSerializerOptions)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not deserialize the resumable session.");
|
||||
return new(new ResumableSession { Phase = state.Phase });
|
||||
}
|
||||
|
||||
private static AgentResponseUpdate NewUpdate(string text) =>
|
||||
new()
|
||||
{
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
Contents = [new TextContent(text)],
|
||||
};
|
||||
|
||||
private sealed class ResumableSession : AgentSession
|
||||
{
|
||||
public int Phase { get; set; }
|
||||
}
|
||||
|
||||
private sealed record SerializedSession(int Phase);
|
||||
}
|
||||
|
||||
private sealed class PhaseObservingSessionStore(
|
||||
AgentSessionStore inner,
|
||||
RecoveryCoordinator coordinator) : AgentSessionStore
|
||||
{
|
||||
public override async ValueTask SaveSessionAsync(
|
||||
AIAgent agent,
|
||||
string conversationId,
|
||||
AgentSession session,
|
||||
string? userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
JsonElement state = await agent.SerializeSessionAsync(
|
||||
session,
|
||||
cancellationToken: cancellationToken);
|
||||
coordinator.SerializedStates.Add(state.GetRawText());
|
||||
await inner.SaveSessionAsync(
|
||||
agent,
|
||||
conversationId,
|
||||
session,
|
||||
userId,
|
||||
cancellationToken);
|
||||
|
||||
JsonProperty? phaseProperty = state
|
||||
.EnumerateObject()
|
||||
.FirstOrDefault(property => string.Equals(
|
||||
property.Name,
|
||||
"phase",
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
if (phaseProperty is { Value.ValueKind: JsonValueKind.Number }
|
||||
&& phaseProperty.Value.Value.GetInt32() == 1)
|
||||
{
|
||||
coordinator.PhasePersisted.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
public override ValueTask<AgentSession?> GetSessionAsync(
|
||||
AIAgent agent,
|
||||
string conversationId,
|
||||
string? userId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
inner.GetSessionAsync(
|
||||
agent,
|
||||
conversationId,
|
||||
userId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private sealed class RecoveryCoordinator
|
||||
{
|
||||
public int FreshRuns;
|
||||
public int RecoveryRuns;
|
||||
|
||||
public string[] RecoveryMessages { get; set; } = [];
|
||||
|
||||
public List<string> SerializedStates { get; } = [];
|
||||
|
||||
public TaskCompletionSource PhasePersisted { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
|
||||
private sealed class CountdownRecoveryCoordinator(int target, int blockAt)
|
||||
{
|
||||
private int _blocked;
|
||||
|
||||
public int Target { get; } = target;
|
||||
|
||||
public TaskCompletionSource Blocked { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public bool ShouldBlock(int value) =>
|
||||
value == blockAt && Interlocked.CompareExchange(ref this._blocked, 1, 0) == 0;
|
||||
}
|
||||
}
|
||||
+45
-1
@@ -15,6 +15,7 @@ using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using OpenAI.Responses;
|
||||
|
||||
@@ -64,7 +65,11 @@ public class ServiceCollectionExtensionsTests
|
||||
var descriptor = services.FirstOrDefault(
|
||||
d => d.ServiceType == typeof(ResponseHandler));
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(typeof(AgentFrameworkResponseHandler), descriptor.ImplementationType);
|
||||
Assert.NotNull(descriptor.ImplementationFactory);
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
Assert.IsType<AgentFrameworkResponseHandler>(
|
||||
provider.GetRequiredService<ResponseHandler>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -95,6 +100,45 @@ public class ServiceCollectionExtensionsTests
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddFoundryResponses_SecondCall_PreservesNonServerOptions()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
// Act
|
||||
services.AddFoundryResponses();
|
||||
services.AddFoundryResponses(options =>
|
||||
options.AllowStoredOutputEnabled = true);
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
// Assert
|
||||
Assert.True(
|
||||
provider.GetRequiredService<IOptions<FoundryResponsesOptions>>()
|
||||
.Value.AllowStoredOutputEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddFoundryResponses_SecondCallEnablesServerFeature_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddFoundryResponses();
|
||||
|
||||
// Act
|
||||
var exception = Assert.Throws<InvalidOperationException>(
|
||||
() => services.AddFoundryResponses(options =>
|
||||
options.SteerableConversations = true));
|
||||
|
||||
// Assert
|
||||
Assert.Contains(
|
||||
"first AddFoundryResponses",
|
||||
exception.Message,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddFoundryResponses_NullServices_ThrowsArgumentNullException()
|
||||
{
|
||||
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
[Collection(FoundryStateStoreLocalFallbackCollectionDefinition.CollectionName)]
|
||||
public sealed class SteerableLongRunningIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ActiveMafTurn_QueuesSteeringThenRunsItOnTheSameSessionAsync()
|
||||
{
|
||||
// Arrange
|
||||
string stateRoot = Path.Combine(Path.GetTempPath(), $"maf-steering-{Guid.NewGuid():N}");
|
||||
string? previousStateRoot = Environment.GetEnvironmentVariable("AGENTSERVER_STATE_ROOT");
|
||||
string? previousHostingEnvironment = Environment.GetEnvironmentVariable("FOUNDRY_HOSTING_ENVIRONMENT");
|
||||
var agent = new GatedSteeringAgent();
|
||||
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("AGENTSERVER_STATE_ROOT", stateRoot);
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_HOSTING_ENVIRONMENT", null);
|
||||
|
||||
await using WebApplication app = await StartServerAsync(agent);
|
||||
using HttpClient client = GetClient(app);
|
||||
string conversationId = $"conv_{Guid.NewGuid():N}";
|
||||
|
||||
using HttpResponseMessage first = await PostTurnAsync(client, conversationId, "first instruction");
|
||||
using JsonDocument firstBody = await ParseAsync(first);
|
||||
string firstResponseId = firstBody.RootElement.GetProperty("id").GetString()!;
|
||||
await agent.FirstTurnEntered.Task.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
// Act
|
||||
using HttpResponseMessage second = await PostTurnAsync(client, conversationId, "steering instruction");
|
||||
using JsonDocument secondBody = await ParseAsync(second);
|
||||
string secondResponseId = secondBody.RootElement.GetProperty("id").GetString()!;
|
||||
|
||||
// Assert: AgentServer queued the second input rather than invoking MAF concurrently.
|
||||
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
|
||||
Assert.Equal("queued", secondBody.RootElement.GetProperty("status").GetString());
|
||||
Assert.Equal(1, agent.RunCount);
|
||||
Assert.Equal(1, agent.MaxConcurrentRuns);
|
||||
|
||||
agent.ReleaseFirstTurn.TrySetResult();
|
||||
await agent.SecondTurnEntered.Task.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
await WaitForTerminalAsync(client, firstResponseId);
|
||||
await WaitForTerminalAsync(client, secondResponseId);
|
||||
|
||||
Assert.Equal(2, agent.RunCount);
|
||||
Assert.Equal(1, agent.MaxConcurrentRuns);
|
||||
Assert.Collection(
|
||||
agent.ObservedTurns,
|
||||
firstTurn =>
|
||||
{
|
||||
Assert.Equal(1, firstTurn.SessionTurn);
|
||||
Assert.Contains("first instruction", firstTurn.Input, StringComparison.Ordinal);
|
||||
},
|
||||
secondTurn =>
|
||||
{
|
||||
Assert.Equal(2, secondTurn.SessionTurn);
|
||||
Assert.Contains("steering instruction", secondTurn.Input, StringComparison.Ordinal);
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
agent.ReleaseFirstTurn.TrySetResult();
|
||||
Environment.SetEnvironmentVariable("AGENTSERVER_STATE_ROOT", previousStateRoot);
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_HOSTING_ENVIRONMENT", previousHostingEnvironment);
|
||||
|
||||
if (Directory.Exists(stateRoot))
|
||||
{
|
||||
Directory.Delete(stateRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<WebApplication> StartServerAsync(AIAgent agent)
|
||||
{
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
builder.Services.AddFoundryResponses(
|
||||
agent,
|
||||
new InMemoryAgentSessionStore(),
|
||||
options =>
|
||||
{
|
||||
options.ResilientBackground = true;
|
||||
options.SteerableConversations = true;
|
||||
});
|
||||
builder.Services.AddSingleton<HostedSessionIsolationKeyProvider>(
|
||||
new FakeHostedSessionIsolationKeyProvider());
|
||||
builder.Services.AddLogging();
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
await app.StartAsync();
|
||||
return app;
|
||||
}
|
||||
|
||||
private static HttpClient GetClient(WebApplication app) =>
|
||||
(app.Services.GetRequiredService<IServer>() as TestServer
|
||||
?? throw new InvalidOperationException("TestServer not found."))
|
||||
.CreateClient();
|
||||
|
||||
private static Task<HttpResponseMessage> PostTurnAsync(
|
||||
HttpClient client,
|
||||
string conversationId,
|
||||
string input)
|
||||
{
|
||||
string body = JsonSerializer.Serialize(new
|
||||
{
|
||||
model = "steering-probe",
|
||||
input,
|
||||
store = true,
|
||||
background = true,
|
||||
conversation = conversationId,
|
||||
});
|
||||
return client.PostAsync(
|
||||
new Uri("/responses", UriKind.Relative),
|
||||
new StringContent(body, Encoding.UTF8, "application/json"));
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> ParseAsync(HttpResponseMessage response) =>
|
||||
JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
|
||||
private static async Task WaitForTerminalAsync(HttpClient client, string responseId)
|
||||
{
|
||||
var deadline = DateTimeOffset.UtcNow.AddSeconds(15);
|
||||
string last = "(none)";
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
using HttpResponseMessage response = await client.GetAsync(
|
||||
new Uri($"/responses/{responseId}", UriKind.Relative));
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
last = $"{(int)response.StatusCode} {body}";
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(body);
|
||||
string? status = document.RootElement.GetProperty("status").GetString();
|
||||
if (status == "completed")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (status is "failed" or "cancelled" or "incomplete")
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Response '{responseId}' terminated with status '{status}'.");
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(25));
|
||||
}
|
||||
|
||||
throw new TimeoutException(
|
||||
$"Response '{responseId}' did not complete. Last response: {last}");
|
||||
}
|
||||
|
||||
private sealed class GatedSteeringAgent : AIAgent
|
||||
{
|
||||
private readonly ConcurrentQueue<ObservedTurn> _observedTurns = new();
|
||||
private int _activeRuns;
|
||||
private int _maxConcurrentRuns;
|
||||
private int _runCount;
|
||||
|
||||
public TaskCompletionSource FirstTurnEntered { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public TaskCompletionSource ReleaseFirstTurn { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public TaskCompletionSource SecondTurnEntered { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public int RunCount => Volatile.Read(ref this._runCount);
|
||||
|
||||
public int MaxConcurrentRuns => Volatile.Read(ref this._maxConcurrentRuns);
|
||||
|
||||
public IReadOnlyList<ObservedTurn> ObservedTurns => this._observedTurns.ToArray();
|
||||
|
||||
public override string? Name => "steering-probe";
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var probeSession = Assert.IsType<ProbeSession>(session);
|
||||
int activeRuns = Interlocked.Increment(ref this._activeRuns);
|
||||
UpdateMaximum(ref this._maxConcurrentRuns, activeRuns);
|
||||
|
||||
try
|
||||
{
|
||||
int run = Interlocked.Increment(ref this._runCount);
|
||||
int sessionTurn = ++probeSession.Turn;
|
||||
string input = string.Join(
|
||||
"\n",
|
||||
messages.Select(message => message.Text).Where(text => text is not null));
|
||||
this._observedTurns.Enqueue(new(sessionTurn, input));
|
||||
|
||||
if (run == 1)
|
||||
{
|
||||
this.FirstTurnEntered.TrySetResult();
|
||||
await this.ReleaseFirstTurn.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
this.SecondTurnEntered.TrySetResult();
|
||||
}
|
||||
|
||||
yield return new AgentResponseUpdate
|
||||
{
|
||||
MessageId = $"msg_{run}",
|
||||
Contents = [new TextContent($"TURN-{sessionTurn}-COMPLETE")],
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Decrement(ref this._activeRuns);
|
||||
}
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(new ProbeSession());
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var probeSession = Assert.IsType<ProbeSession>(session);
|
||||
return new(JsonSerializer.SerializeToElement(
|
||||
new SerializedSession(probeSession.Turn),
|
||||
jsonSerializerOptions));
|
||||
}
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
SerializedSession state = serializedState.Deserialize<SerializedSession>(
|
||||
jsonSerializerOptions)
|
||||
?? throw new InvalidOperationException("Could not deserialize the steering session.");
|
||||
return new(new ProbeSession { Turn = state.Turn });
|
||||
}
|
||||
|
||||
private static void UpdateMaximum(ref int maximum, int candidate)
|
||||
{
|
||||
int current;
|
||||
do
|
||||
{
|
||||
current = Volatile.Read(ref maximum);
|
||||
if (candidate <= current)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
while (Interlocked.CompareExchange(ref maximum, candidate, current) != current);
|
||||
}
|
||||
|
||||
private sealed class ProbeSession : AgentSession
|
||||
{
|
||||
public int Turn { get; set; }
|
||||
}
|
||||
|
||||
private sealed record SerializedSession(int Turn);
|
||||
}
|
||||
|
||||
private sealed record ObservedTurn(int SessionTurn, string Input);
|
||||
}
|
||||
+38
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.InProc;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
@@ -133,6 +134,41 @@ public class WorkflowHostingExtensionsTests
|
||||
Assert.True(metadata.UsesOwnCheckpointStorage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetService_WorkflowSession_ExposesCheckpointRecoveryAsync()
|
||||
{
|
||||
// Arrange
|
||||
AIAgent agent = BuildWorkflowAgent(
|
||||
InProcessExecution.Lockstep.WithCheckpointing(CheckpointManager.CreateInMemory()));
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
WorkflowSessionCheckpointRecovery recovery = session.GetService<WorkflowSessionCheckpointRecovery>()
|
||||
?? throw new InvalidOperationException("Workflow checkpoint recovery was not available.");
|
||||
|
||||
// Act
|
||||
bool prepared = recovery.TryPrepare("checkpoint-from-response");
|
||||
|
||||
// Assert
|
||||
Assert.True(prepared);
|
||||
CheckpointInfo? checkpoint = recovery.CurrentCheckpoint;
|
||||
Assert.NotNull(checkpoint);
|
||||
Assert.Equal("checkpoint-from-response", checkpoint.CheckpointId);
|
||||
Assert.False(string.IsNullOrWhiteSpace(checkpoint.SessionId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetService_NonWorkflowSession_HasNoCheckpointRecovery()
|
||||
{
|
||||
// Arrange
|
||||
var session = new NonWorkflowSession();
|
||||
|
||||
// Act
|
||||
WorkflowSessionCheckpointRecovery? recovery =
|
||||
session.GetService<WorkflowSessionCheckpointRecovery>();
|
||||
|
||||
// Assert
|
||||
Assert.Null(recovery);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetService_WorkflowAgentBehindAWrapper_IsStillFound()
|
||||
{
|
||||
@@ -180,4 +216,6 @@ public class WorkflowHostingExtensionsTests
|
||||
}
|
||||
|
||||
private sealed class PassThroughAgent(AIAgent innerAgent) : DelegatingAIAgent(innerAgent);
|
||||
|
||||
private sealed class NonWorkflowSession : AgentSession;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user