Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] 7433aab92b Bump github/codeql-action/analyze from 4.37.3 to 4.37.7
Bumps [github/codeql-action/analyze](https://github.com/github/codeql-action) from 4.37.3 to 4.37.7.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

---
updated-dependencies:
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-20 10:18:58 +00:00
343 changed files with 2264 additions and 25650 deletions
+3 -3
View File
@@ -66,8 +66,8 @@
/python/packages/declarative/ @chetantoshniwal @moonbox3 @peibekwe
/python/packages/devui/ @chetantoshniwal @eavanvalkenburg @moonbox3
/python/packages/foundry/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @moonbox3 @giles17
/python/packages/foundry_hosting/ @chetantoshniwal @TaoChenOSU @eavanvalkenburg @moonbox3
/python/packages/foundry_local/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
/python/packages/foundry_hosting/ @chetantoshniwal @TaoChenOSU @eavanvalkenburg
/python/packages/foundry_local/ @chetantoshniwal @eavanvalkenburg @giles17
/python/packages/gemini/ @chetantoshniwal @giles17 @eavanvalkenburg @moonbox3
/python/packages/github_copilot/ @chetantoshniwal @giles17 @eavanvalkenburg @moonbox3
/python/packages/hosting/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @moonbox3
@@ -113,7 +113,6 @@
/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
/dotnet/src/Microsoft.Agents.AI.Hyperlight/ @chetantoshniwal @westey-m @SergeyMenshykh
/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ @chetantoshniwal @westey-m @SergeyMenshykh
@@ -128,3 +127,4 @@
/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/ @chetantoshniwal @peibekwe @rogerbarreto
/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/ @chetantoshniwal @peibekwe @rogerbarreto
/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ @chetantoshniwal @peibekwe @rogerbarreto
-13
View File
@@ -1,13 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
/**
* Check whether a comment contains only the DevFlow review command.
*
* @param {unknown} body - Issue comment body from the GitHub event payload.
* @returns {boolean} Whether the normalized comment is exactly `/review`.
*/
function isReviewCommand(body) {
return typeof body === 'string' && body.trim() === '/review';
}
module.exports = isReviewCommand;
-37
View File
@@ -1,37 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
/**
* Tests for review_command.js.
*
* Run with: node --test .github/tests/test_review_command.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const isReviewCommand = require('../scripts/review_command.js');
describe('review command validation', () => {
it('accepts the exact review command', () => {
assert.equal(isReviewCommand('/review'), true);
});
it('accepts surrounding whitespace', () => {
assert.equal(isReviewCommand('/review\r\n'), true);
assert.equal(isReviewCommand(' \n/review\t'), true);
});
it('rejects commands with additional content', () => {
assert.equal(isReviewCommand('/reviewer'), false);
assert.equal(isReviewCommand('/review please'), false);
assert.equal(isReviewCommand('/review\nadditional text'), false);
assert.equal(isReviewCommand('/Review'), false);
});
it('rejects missing or non-string comment bodies', () => {
assert.equal(isReviewCommand(''), false);
assert.equal(isReviewCommand(null), false);
assert.equal(isReviewCommand(undefined), false);
});
});
+1 -1
View File
@@ -64,6 +64,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: "/language:${{matrix.language}}"
+2 -26
View File
@@ -34,42 +34,18 @@ env:
MODEL_CONFIG_PATH: ${{ github.workspace }}/devflow/config.ci.yaml
jobs:
command_check:
team_check:
if: >-
github.event_name != 'issue_comment' ||
(
github.event.issue.pull_request &&
github.event.comment.body == '/review' &&
(
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'OWNER'
)
)
runs-on: ubuntu-latest
outputs:
should_review: ${{ steps.check.outputs.should_review }}
steps:
- name: Checkout review command validation
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
sparse-checkout: .github/scripts/review_command.js
fetch-depth: 1
persist-credentials: false
- name: Check review command
id: check
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const isReviewCommand = require('./.github/scripts/review_command.js');
const shouldReview = context.eventName !== 'issue_comment' ||
isReviewCommand(context.payload.comment?.body);
core.setOutput('should_review', shouldReview ? 'true' : 'false');
team_check:
needs: command_check
if: ${{ needs.command_check.outputs.should_review == 'true' }}
runs-on: ubuntu-latest
environment: github-app-auth
outputs:
is_team_member: ${{ steps.check.outputs.is_team_member }}
@@ -37,7 +37,6 @@ jobs:
outputs:
dotnetChanges: ${{ steps.filter.outputs.dotnet }}
cosmosDbChanges: ${{ steps.filter.outputs.cosmosdb }}
azureStorageChanges: ${{ steps.filter.outputs.azurestorage }}
foundryHostingChanges: ${{ steps.filter.outputs.foundryHosting }}
coreChanged: ${{ steps.filter.outputs.core }}
steps:
@@ -50,12 +49,6 @@ jobs:
- 'dotnet/**'
cosmosdb:
- 'dotnet/src/Microsoft.Agents.AI.CosmosNoSql/**'
azurestorage:
- 'dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/**'
- 'dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.IntegrationTests/**'
- 'dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/**'
- 'dotnet/Directory.Packages.props'
- '.github/workflows/dotnet-build-and-test.yml'
# The Foundry hosted-agent IT is costly (builds a container, pushes to ACR,
# provisions live agents). Only run it when the project under test, its
# dependency chain, the test container, the test fixture, or their tooling
@@ -127,7 +120,6 @@ jobs:
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
- name: Build dotnet solutions
shell: bash
run: |
@@ -192,7 +184,6 @@ jobs:
.
.github
dotnet
docs/specs
python
declarative-agents
@@ -209,27 +200,6 @@ jobs:
Start-CosmosDbEmulator -NoUI -Key "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
echo "COSMOSDB_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
- name: Start Azurite Blob service
if: ${{ runner.os == 'Linux' && (needs.paths-filter.outputs.azureStorageChanges == 'true' || (github.event_name != 'pull_request' && matrix.integration-tests)) }}
shell: bash
run: |
docker run --detach --rm \
--name azurite \
--publish 10000:10000 \
mcr.microsoft.com/azure-storage/azurite:3.35.0@sha256:647c63a91102a9d8e8000aab803436e1fc85fbb285e7ce830a82ee5d6661cf37 \
azurite-blob --blobHost 0.0.0.0 --blobPort 10000 --skipApiVersionCheck
for attempt in {1..30}; do
if (echo > /dev/tcp/127.0.0.1/10000) > /dev/null 2>&1; then
echo "AZURITE_AVAILABLE=true" >> "$GITHUB_ENV"
exit 0
fi
sleep 1
done
docker logs azurite
exit 1
- name: Setup dotnet
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
+13 -82
View File
@@ -33,97 +33,28 @@ jobs:
env:
# Configure a constant location for the uv cache
UV_CACHE_DIR: /tmp/.uv-cache
- name: Resolve the package to build
env:
TAG_NAME: ${{ github.event.release.tag_name }}
- name: Set environment variables
run: |
set -euo pipefail
# Extract package name from tag (format: python-<package>-<version>)
TAG="${{ github.event.release.tag_name }}"
PACKAGE=$(echo "$TAG" | sed 's/^python-\([^-]*\)-.*$/\1/')
TAG="$TAG_NAME"
# Release tags are either python-<version> for the whole workspace, or
# python-<package>-<version> for a single package. Package names may
# themselves contain hyphens (hosting-a2a, azure-ai-search), so the
# package part cannot be found by splitting on the first hyphen.
#
# Versions follow the lifecycle patterns in the python-package-management
# skill: X.Y.Z, X.Y.ZaYYMMDD, X.Y.ZbYYMMDD, X.Y.ZrcN, each optionally
# carrying a .N or .postN re-cut suffix.
VERSION_PATTERN='^[0-9]+\.[0-9]+\.[0-9]+([ab][0-9]+|rc[0-9]+)?(\.[0-9]+|\.post[0-9]+)?$'
REST="${TAG#python-}"
if [[ -z "$REST" ]]; then
echo "Error: tag '$TAG' has no version or package component"
# Validate package exists
if [[ ! -d "packages/$PACKAGE" ]]; then
echo "Error: Package '$PACKAGE' not found in packages/ directory"
echo "Available packages: $(ls packages/)"
exit 1
fi
if [[ "$REST" =~ $VERSION_PATTERN ]]; then
# python-<version>: build every workspace package plus the root meta package.
PACKAGE="all"
echo "Resolved tag '$TAG' to the full workspace build"
else
# python-<package>-<version>: split off the trailing version component and
# require it to be a real version, so a malformed tag fails here rather
# than being mistaken for another kind of release.
CANDIDATE="${REST%-*}"
VERSION="${REST##*-}"
if [[ "$CANDIDATE" == "$REST" || -z "$CANDIDATE" ]]; then
echo "Error: tag '$TAG' is neither python-<version> nor python-<package>-<version>"
exit 1
fi
if [[ ! "$VERSION" =~ $VERSION_PATTERN ]]; then
echo "Error: tag '$TAG' does not end in a supported version"
echo "Derived version: '$VERSION'"
exit 1
fi
# Resolve the package part against the real package directories. Tags use
# hyphens even where the directory uses underscores
# (python-github-copilot -> github_copilot).
PACKAGE=""
for dir in packages/*/; do
name="${dir#packages/}"
name="${name%/}"
if [[ "$name" == "$CANDIDATE" || "${name//_/-}" == "$CANDIDATE" ]]; then
PACKAGE="$name"
break
fi
done
if [[ -z "$PACKAGE" ]]; then
echo "Error: tag '$TAG' does not map to a directory in packages/"
echo "Derived package name: '$CANDIDATE'"
echo "Available packages: $(ls packages/)"
exit 1
fi
echo "Resolved tag '$TAG' to package '$PACKAGE'"
fi
echo "PACKAGE=$PACKAGE" >> "$GITHUB_ENV"
echo "PACKAGE=$PACKAGE" >> $GITHUB_ENV
echo "Building package: $PACKAGE"
- name: Check version
env:
TAG_NAME: ${{ github.event.release.tag_name }}
run: |
echo "Building and uploading Python release: $TAG_NAME"
if [[ "$PACKAGE" == "all" ]]; then
echo "Build scope: all workspace packages and the root meta package"
else
echo "Build scope: packages/$PACKAGE"
fi
echo "Building and uploading Python package version: ${{ github.event.release.tag_name }}"
echo "Package directory: packages/${{ env.PACKAGE }}"
- name: Build the package
run: |
set -euo pipefail
if [[ "$PACKAGE" == "all" ]]; then
uv run poe build
else
uv run poe --directory "packages/$PACKAGE" build
fi
run: uv run poe --directory packages/${{ env.PACKAGE }} build
- name: Release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
-4
View File
@@ -207,10 +207,6 @@ temp*/
# AI
**/.checkpoints/
# Local AgentServer file store + crash-recovery HOME roots used by hosted samples
**/.agentserver-state/
**/.agentserver-state-*/
**/.home-*/
.claude/
.omc/
.omx/
+1 -5
View File
@@ -11,10 +11,7 @@
Microsoft Agent Framework (MAF) is an open, multi-language framework for building **production-grade AI agents and multi-agent workflows** in **.NET and Python**.
Microsoft Agent Framework is built for teams taking agents from prototype to production. It provides a consistent foundation for building, orchestrating, and operating agent systems across Python, .NET and Go, while keeping architecture choices open as requirements evolve, and supports a broad ecosystem including Microsoft Foundry, Azure OpenAI, OpenAI, and the GitHub Copilot SDK, with samples and hosting patterns for both local development and cloud deployment.
> [!NOTE]
> For the Go SDK, including its documentation, samples, contribution guidance, and issue tracker, visit [microsoft/agent-framework-go](https://github.com/microsoft/agent-framework-go/).
Microsoft Agent Framework is built for teams taking agents from prototype to production. It provides a consistent foundation for building, orchestrating, and operating agent systems across Python and .NET, while keeping architecture choices open as requirements evolve, and supports a broad ecosystem including Microsoft Foundry, Azure OpenAI, OpenAI, and the GitHub Copilot SDK, with samples and hosting patterns for both local development and cloud deployment.
<p align="center">
<a href="https://www.youtube.com/watch?v=AAgdMhftj8w" title="Watch the full Agent Framework introduction (30 min)">
@@ -42,7 +39,6 @@ Explore new MAF capabilities and real implementation patterns on the [official b
- **Python and C#/.NET Support**: Full framework support for both Python and C#/.NET implementations with consistent APIs
- [Python packages](./python/packages/) | [.NET source](./dotnet/src/)
- **Go Support**: For the Go SDK, including its documentation, samples, contribution guidance, and issue tracker, visit [microsoft/agent-framework-go](https://github.com/microsoft/agent-framework-go/).
- **Multiple Agent Provider Support**: Support for various LLM providers with more being added continuously
- [Python examples](./python/samples/02-agents/providers/) | [.NET examples](./dotnet/samples/02-agents/AgentProviders/)
- **Middleware**: Flexible middleware system for request/response processing, exception handling, and custom pipelines
@@ -1,191 +0,0 @@
---
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.
+10 -12
View File
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<!-- Aspire.* -->
<PackageVersion Include="Anthropic" Version="12.42.0" />
<PackageVersion Include="Anthropic" Version="12.35.1" />
<PackageVersion Include="Anthropic.Foundry" Version="0.7.1" />
<PackageVersion Include="Aspire.Hosting" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="13.0.0-preview.1.25560.3" />
@@ -23,16 +23,15 @@
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0" />
<PackageVersion Include="MessagePack" Version="3.1.7" /> <!-- Transitive dependency of Aspire pinned to newer version due to vulnerability in 2.5.192 -->
<!-- Azure.* -->
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.28" />
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.6" />
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.8" />
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.26" />
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.5" />
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.6" />
<PackageVersion Include="Azure.Search.Documents" Version="12.0.0" />
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.4" />
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
<PackageVersion Include="Azure.Core" Version="1.61.0" />
<PackageVersion Include="Azure.Core" Version="1.60.0" />
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
<PackageVersion Include="Azure.Storage.Blobs" Version="12.29.1" />
<PackageVersion Include="DotNetEnv" Version="3.1.1" />
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.5.0" />
<!-- Google Gemini -->
@@ -45,15 +44,15 @@
<!-- System.* -->
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.11" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.10" />
<PackageVersion Include="System.ClientModel" Version="1.15.0" />
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.5" />
<PackageVersion Include="System.ClientModel" Version="1.14.0" />
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
<PackageVersion Include="System.Collections.Immutable" Version="10.0.10" />
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.11" />
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.5" />
<PackageVersion Include="System.Net.Http.Json" Version="10.0.0" />
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.10" />
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.8" />
<!-- AG-UI .NET SDK packages (published by the AG-UI team). -->
<PackageVersion Include="AGUI.Abstractions" Version="0.0.5" />
<PackageVersion Include="AGUI.Formatting" Version="0.0.5" />
@@ -120,8 +119,7 @@
<PackageVersion Include="A2A" Version="1.0.0-preview2" />
<PackageVersion Include="A2A.AspNetCore" Version="1.0.0-preview2" />
<!-- MCP -->
<PackageVersion Include="ModelContextProtocol" Version="2.1.0" />
<PackageVersion Include="ModelContextProtocol.Extensions.Tasks" Version="2.1.0" />
<PackageVersion Include="ModelContextProtocol" Version="1.2.0" />
<!-- Hyperlight -->
<PackageVersion Include="Hyperlight.HyperlightSandbox.Api" Version="0.4.0" />
<PackageVersion Include="Hyperlight.HyperlightSandbox.Guest.Python" Version="0.4.0" />
+1
View File
@@ -33,3 +33,4 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram
- [Design Documents](../docs/design)
- [Architectural Decision Records](../docs/decisions)
- [MSFT Learn Docs](https://learn.microsoft.com/agent-framework/overview/agent-framework-overview)
+1 -18
View File
@@ -377,23 +377,11 @@
<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>
@@ -617,7 +605,6 @@
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.AspNetCore/Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureStorage/Microsoft.Agents.AI.Hosting.AzureStorage.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj" />
@@ -649,7 +636,6 @@
<Project Path="tests/Foundry.IntegrationTests/Foundry.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureStorage.IntegrationTests/Microsoft.Agents.AI.Hosting.AzureStorage.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/Microsoft.Agents.AI.Hyperlight.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Microsoft.Agents.AI.Mem0.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Tools.Shell.IntegrationTests/Microsoft.Agents.AI.Tools.Shell.IntegrationTests.csproj" />
@@ -668,16 +654,12 @@
<Project Path="tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/Microsoft.Agents.AI.CosmosNoSql.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Declarative.UnitTests/Microsoft.Agents.AI.Declarative.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.FeatureRegistry.UnitTests/Microsoft.Agents.AI.FeatureRegistry.UnitTests.csproj">
<Build Solution="Debug|*" Project="false" />
</Project>
<Project Path="tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Harness.UnitTests/Microsoft.Agents.AI.Harness.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj" />
@@ -695,3 +677,4 @@
<Project Path="tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj" />
</Folder>
</Solution>
-1
View File
@@ -20,7 +20,6 @@
"src\\Microsoft.Agents.AI.Hosting.A2A\\Microsoft.Agents.AI.Hosting.A2A.csproj",
"src\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj",
"src\\Microsoft.Agents.AI.Hosting.AspNetCore\\Microsoft.Agents.AI.Hosting.AspNetCore.csproj",
"src\\Microsoft.Agents.AI.Hosting.AzureStorage\\Microsoft.Agents.AI.Hosting.AzureStorage.csproj",
"src\\Microsoft.Agents.AI.Hosting.OpenAI\\Microsoft.Agents.AI.Hosting.OpenAI.csproj",
"src\\Microsoft.Agents.AI.Hosting\\Microsoft.Agents.AI.Hosting.csproj",
"src\\Microsoft.Agents.AI.LocalCodeAct\\Microsoft.Agents.AI.LocalCodeAct.csproj",
-3
View File
@@ -32,7 +32,4 @@
<ItemGroup Condition="'$(InjectSharedRedaction)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\Redaction\*.cs" LinkBase="Shared\Redaction" />
</ItemGroup>
<ItemGroup Condition="'$(InjectSharedFeatureUsageUserAgent)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\FeatureUsage\*.cs" LinkBase="Shared\FeatureUsage" />
</ItemGroup>
</Project>
@@ -1303,7 +1303,6 @@ internal static class AgentsSamples
OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"],
MustContain =
[
"MCP 2026-07-28 Tasks extension enabled.",
"=== Transparent long-running MCP task (RunAsync) ===",
"=== Transparent long-running MCP task (RunStreamingAsync) ===",
],
+3 -3
View File
@@ -1,14 +1,14 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.19.0</VersionPrefix>
<VersionPrefix>1.18.0</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260822</DateSuffix>
<DateSuffix>260818</DateSuffix>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
<GitTag>1.19.0</GitTag>
<GitTag>1.18.0</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -38,8 +38,8 @@
<ItemGroup>
<!-- AgentMemory (published) — an unofficial .NET port of the Neo4j Labs agent-memory library + its
Microsoft Agent Framework adapter. -->
<PackageReference Include="AgentMemory" Version="1.4.1" />
<PackageReference Include="AgentMemory.AgentFramework" Version="1.4.1" />
<PackageReference Include="AgentMemory" Version="1.3.0" />
<PackageReference Include="AgentMemory.AgentFramework" Version="1.3.0" />
<!-- Microsoft Agent Framework (matches AgentMemory's target) + the OpenAI/Foundry chat & embedding clients. -->
<PackageReference Include="Microsoft.Agents.AI" Version="1.9.0" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.5.1" />
@@ -14,58 +14,6 @@ This sample demonstrates how to use a `CompactionProvider` with a `PipelineCompa
## Concepts
### Choosing between `CompactionProvider` and `IChatReducer`
Both abstractions reduce the messages sent to a model, but they run at different layers and have different effects on stored history.
| Choose | When you need | Effect on stored history | Function-calling loop |
|---|---|---|---|
| `CompactionProvider` on `ChatClientBuilder.UseAIContextProviders(...)` | Request-context management that preserves the original conversation | The compacted view is forwarded to the inner chat client; the source history remains unchanged | Runs for each inner chat-client call, including calls made while tools are being invoked |
| `CompactionProvider` in `ChatClientAgentOptions.AIContextProviders` | Agent-specific compaction without decorating a shared chat client | Runs before chat history is stored, so generated replacement messages can become part of the persisted history | Runs at the agent boundary, not for each call inside the tool loop |
| `IChatReducer` in `InMemoryChatHistoryProviderOptions.ChatReducer` | Storage management where the reduced list should replace the session's in-memory history | Permanently replaces the provider's stored message list with the reducer output | Runs at the configured history-provider event, not for each call inside the tool loop |
Use a builder-level `CompactionProvider` when the primary goal is to fit each model request within a context window while retaining the complete conversation for auditing, replay, or a different downstream policy. Use an `IChatReducer` when the primary goal is to bound the history retained in `InMemoryChatHistoryProvider` itself. If the reduced history is serialized with the session, the discarded messages are no longer present after the session is restored.
`InMemoryChatHistoryProvider` can run its reducer at either of these events:
- `BeforeMessagesRetrieval` (the default) reduces stored history immediately before it is supplied to the agent.
- `AfterMessageAdded` reduces stored history after each request/response pair is added.
The event controls *when* reduction occurs; the `IChatReducer` implementation controls *how* messages are reduced. By contrast, a `CompactionStrategy` supplies its own `CompactionTrigger` and operates on message groups that preserve tool-call/result pairs.
#### Adapting between the abstractions
The adapters support existing implementations at either integration point. Pick the direction that matches the layer where you want reduction to run.
To use a `CompactionStrategy` for persistent in-memory history reduction, adapt it to `IChatReducer`:
```csharp
CompactionStrategy strategy =
new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(20));
InMemoryChatHistoryProviderOptions historyOptions = new()
{
ChatReducer = strategy.AsChatReducer(),
ReducerTriggerEvent = InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval
};
InMemoryChatHistoryProvider historyProvider = new(historyOptions);
```
To use an existing `IChatReducer` in a compaction pipeline or for in-run request compaction, adapt it to `CompactionStrategy`:
```csharp
IChatReducer existingReducer = /* your MEAI reducer */;
CompactionStrategy strategy = new ChatReducerCompactionStrategy(
existingReducer,
CompactionTriggers.TokensExceed(4000));
CompactionProvider provider = new(strategy);
```
Do not wrap a strategy with `AsChatReducer()` and immediately wrap that reducer in `ChatReducerCompactionStrategy`. That round trip adds no capability; choose the original strategy directly and register it at the appropriate layer.
### Message groups
The compaction engine organizes messages into atomic *groups* that are treated as indivisible units during compaction. A group is either:
+1 -1
View File
@@ -44,7 +44,7 @@ Before you begin, ensure you have the following prerequisites:
|[Deep research with an agent](./Agent_Step15_DeepResearch/)|This sample demonstrates how to use the Deep Research Tool to perform comprehensive research on complex topics|
|[Declarative agent](./Agent_Step16_Declarative/)|This sample demonstrates how to declaratively define an agent.|
|[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step17_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.|
|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline and how to choose between request-level `CompactionProvider` and persistent-history `IChatReducer` integration.|
|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.|
|[In-function-loop checkpointing](./Agent_Step19_InFunctionLoopCheckpointing/)|This sample demonstrates how to persist chat history after each service call during a tool-calling loop, enabling crash recovery and mid-run observability.|
|[Dynamic function tools](./Agent_Step20_DynamicFunctionTools/)|This sample demonstrates how to dynamically expand the set of function tools available to an agent during a function-calling loop using the ambient FunctionInvocationContext.|
|[Shell tool with environment-aware system prompt](./Agent_Step21_ShellWithEnvironment/)|This sample demonstrates how to use the shell tool together with the ShellEnvironmentProvider to run commands in stateless and persistent modes, injecting environment-aware instructions so the agent emits commands in the right shell idiom.|
@@ -15,7 +15,6 @@
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="ModelContextProtocol" />
<PackageReference Include="ModelContextProtocol.Extensions.Tasks" />
</ItemGroup>
<ItemGroup>
@@ -5,7 +5,7 @@
// A small MCP server (hosted in this same executable when launched with "--server") exposes
// a single task-supporting tool "AnalyzeDataset" that simulates ~15 seconds of work. The
// client (default mode) connects to it over stdio via Microsoft.Agents.AI.Mcp's
// McpClientTaskExtensions.ListAgentToolsWithTasksAsync, hands the wrapped tools to a
// McpClientTaskExtensions.ListAgentToolsWithTaskSupportAsync, hands the wrapped tools to a
// ChatClientAgent, and exercises both invocation styles:
// * RunAsync — blocks until the agent's final response is ready.
// * RunStreamingAsync — yields response updates as the model produces them; the model
@@ -14,9 +14,9 @@
// tool execution time, not stream-channel latency.
//
// In both cases the wrapper transparently:
// 1. Calls tools/call with the io.modelcontextprotocol/tasks extension capability
// 2. Accepts either an inline result or a task handle
// 3. Polls tasks/get until the final result is available
// 1. Calls tools/call with task augmentation (CallToolAsTaskAsync)
// 2. Polls tasks/get until terminal (PollTaskUntilCompleteAsync)
// 3. Fetches tasks/result and returns the final result to the function-calling loop
//
// No application-level loop or continuation tokens are required in either mode.
@@ -29,8 +29,8 @@ using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Extensions.Tasks;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using OpenAI.Chat;
@@ -53,9 +53,15 @@ await using var mcpClient = await McpClient.CreateAsync(new StdioClientTransport
Arguments = [thisAssemblyPath, "--server"],
}));
// Wrap each MCP tool with task-aware behavior. Each invocation opts into the Tasks extension;
// a task-capable server may return a task handle, while other servers can return inline.
var mcpTools = await mcpClient.ListAgentToolsWithTasksAsync();
// Wrap each MCP tool with task-aware behavior. The wrapper inspects the server's
// execution.taskSupport on each tool and, when it is Required, drives the task lifecycle
// transparently within the agent's tool loop. Tools that don't require task semantics are
// returned as-is and invoked inline.
var taskOptions = new McpTaskOptions
{
DefaultTimeToLive = TimeSpan.FromMinutes(5),
};
var mcpTools = await mcpClient.ListAgentToolsWithTaskSupportAsync(taskOptions);
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
@@ -70,8 +76,6 @@ AIAgent agent = new AzureOpenAIClient(
const string Prompt = "Analyze the dataset named 'sales-2025-q1' and summarize the findings.";
Console.WriteLine("MCP 2026-07-28 Tasks extension enabled.");
Console.WriteLine();
Console.WriteLine("=== Transparent long-running MCP task (RunAsync) ===");
Console.WriteLine("Asking the agent to analyze a dataset; the tool takes ~15s to complete.");
Console.WriteLine("RunAsync blocks while the wrapper polls the task to completion.");
@@ -113,10 +117,12 @@ static async Task RunMcpServerAsync()
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Services.AddMcpServer(o =>
o.ServerInfo = new Implementation { Name = "DatasetAnalyzer", Version = "1.0.0" })
{
o.TaskStore = new InMemoryMcpTaskStore();
o.ServerInfo = new Implementation { Name = "DatasetAnalyzer", Version = "1.0.0" };
})
.WithStdioServerTransport()
.WithTools<DatasetAnalysisTools>()
.WithTasks(new InMemoryMcpTaskStore());
.WithTools<DatasetAnalysisTools>();
await builder.Build().RunAsync();
}
@@ -126,7 +132,7 @@ static async Task RunMcpServerAsync()
internal sealed class DatasetAnalysisTools
#pragma warning restore CA1812
{
[McpServerTool(Name = "AnalyzeDataset")]
[McpServerTool(Name = "AnalyzeDataset", TaskSupport = ToolTaskSupport.Required)]
[Description("Analyze a tabular dataset and return summary statistics. This tool simulates a long-running analytic job (~15 seconds).")]
public static async Task<string> AnalyzeDatasetAsync(
[Description("The dataset identifier, e.g. 'sales-2025-q1'.")] string datasetName,
@@ -1,23 +1,19 @@
# Agent with MCP Tasks extension (transparent polling)
# Agent with MCP long-running task (transparent polling)
This sample demonstrates Microsoft Agent Framework's support for the MCP 2026-07-28 Tasks extension: an agent invokes an MCP tool whose execution takes too long for a single request/response cycle, and the framework polls it to completion behind the function-calling loop. From the agent's perspective the tool simply returns its result.
This sample demonstrates Microsoft Agent Framework's MCP long-running task support: an agent invokes an MCP tool whose execution takes too long for a single request/response cycle, and the framework polls it to completion behind the function-calling loop. From the agent's perspective the tool simply returns its result.
## What this sample shows
- Using `McpClient.ListAgentToolsWithTasksAsync(...)` (in `Microsoft.Agents.AI.Mcp`) to wrap MCP tools with task-aware behavior.
- Hosting a small MCP server (in this same executable, launched with `--server`) that enables `io.modelcontextprotocol/tasks` with `WithTasks(...)` and exposes a tool that sleeps for ~15 seconds.
- Allowing the server to return either an inline result or a task handle after the client opts into the extension.
- Using `McpClient.ListAgentToolsWithTaskSupportAsync(...)` (in `Microsoft.Agents.AI.Mcp`) to wrap MCP tools with task-aware behavior.
- Configuring `McpTaskOptions.DefaultTimeToLive` to bound the server-side task.
- Hosting a small MCP server (in this same executable, launched with `--server`) that advertises `execution.taskSupport=required` on a tool that sleeps for ~15 seconds.
- No application-level polling, continuation tokens, or `AllowBackgroundResponses` flag are required.
The decorator drives the lifecycle internally:
1. `tools/call` includes the Tasks extension capability.
2. The server returns either the ordinary tool result or a task handle.
3. `tasks/get` is polled until it carries the final result, which is returned to the function-calling loop.
The transparent adapter retains the created task handle while it polls. By default, cancelling the local invocation also sends a best-effort `tasks/cancel` so abandoned server work can stop cooperatively. Set `McpTaskOptions.CancelRemoteTaskOnLocalCancellation` to `false` when server work should continue independently after the caller stops waiting.
The adapter also rejects unusable server polling intervals and bounds unique mid-flight input requests. If either safety limit is exceeded while the task may still be active, the adapter fails the invocation and sends a best-effort `tasks/cancel`. `McpTaskOptions` can adjust the remote-cancellation timeout and accepted polling-interval range when deployment requirements differ from the defaults.
1. `tools/call` augmented with task metadata (`CallToolAsTaskAsync`)
2. `tasks/get` polled until terminal (`PollTaskUntilCompleteAsync`)
3. `tasks/result` retrieved (`GetTaskResultAsync`) and returned to the function-calling loop
The sample exercises both invocation styles against the same wrapper:
@@ -10,7 +10,6 @@ using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Authentication;
using ModelContextProtocol.Client;
using OpenAI.Chat;
@@ -40,7 +39,7 @@ var transport = new HttpClientTransport(new()
ClientName = "ProtectedMcpClient",
},
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationCallbackHandler = HandleAuthorizationCallbackAsync,
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
}
}, httpClient, consoleLoggerFactory);
@@ -64,14 +63,12 @@ Console.WriteLine(await agent.RunAsync("Get current weather alerts for New York?
// Handles the OAuth authorization URL by starting a local HTTP server and opening a browser.
// This implementation demonstrates how SDK consumers can provide their own authorization flow.
static async Task<AuthorizationResult?> HandleAuthorizationCallbackAsync(
AuthorizationCallbackContext callbackContext,
CancellationToken cancellationToken)
static async Task<string?> HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken)
{
Console.WriteLine("Starting OAuth authorization flow...");
Console.WriteLine($"Opening browser to: {callbackContext.AuthorizationUri}");
Console.WriteLine($"Opening browser to: {authorizationUrl}");
var listenerPrefix = callbackContext.RedirectUri.GetLeftPart(UriPartial.Authority);
var listenerPrefix = redirectUri.GetLeftPart(UriPartial.Authority);
if (!listenerPrefix.EndsWith("/", StringComparison.InvariantCultureIgnoreCase))
{
listenerPrefix += "/";
@@ -85,13 +82,11 @@ static async Task<AuthorizationResult?> HandleAuthorizationCallbackAsync(
listener.Start();
Console.WriteLine($"Listening for OAuth callback on: {listenerPrefix}");
OpenBrowser(callbackContext.AuthorizationUri);
OpenBrowser(authorizationUrl);
var context = await listener.GetContextAsync();
var query = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? string.Empty);
var code = query["code"];
var state = query["state"];
var issuer = query["iss"];
var error = query["error"];
const string ResponseHtml = "<html><body><h1>Authentication complete</h1><p>You can close this window now.</p></body></html>";
@@ -107,19 +102,14 @@ static async Task<AuthorizationResult?> HandleAuthorizationCallbackAsync(
return null;
}
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
if (string.IsNullOrEmpty(code))
{
Console.WriteLine("The authorization response did not contain both code and state.");
Console.WriteLine("No authorization code received");
return null;
}
Console.WriteLine("Authorization code received successfully.");
return new AuthorizationResult
{
Code = code,
State = state,
Iss = issuer,
};
return code;
}
catch (Exception ex)
{
@@ -23,7 +23,7 @@ Before you begin, ensure you have the following prerequisites:
|[Agent with MCP server tools and authorization](./Agent_MCP_Server_Auth/)|This sample demonstrates how to use MCP Server tools from a protected MCP server with a simple agent|
|[Agent with per-run MCP authentication headers](./Agent_MCP_PerRun_AuthHeaders/)|This sample demonstrates how to attach per-run, refreshable authentication headers to MCP requests using a custom HttpClient handler and an AsyncLocal scope. Uses Microsoft Foundry (`FOUNDRY_PROJECT_ENDPOINT` / `FOUNDRY_MODEL`) rather than the Azure OpenAI variables in the prerequisites above.|
|[Responses Agent with Hosted MCP tool](./ResponseAgent_Hosted_MCP/)|This sample demonstrates how to use the Hosted MCP tool with the Responses Service, where the service invokes any MCP tools directly|
|[Agent with MCP Tasks extension (transparent polling)](./Agent_MCP_LongRunningTask_Client/)|This sample demonstrates how an agent transparently drives an MCP 2026-07-28 Tasks extension invocation to completion. The wrapper handles inline fallback and polls task-backed calls internally for both `RunAsync` and `RunStreamingAsync`.|
|[Agent with long-running MCP task (transparent polling)](./Agent_MCP_LongRunningTask_Client/)|This sample demonstrates how an agent transparently drives a long-running MCP task (SEP-2663) to completion. The wrapper polls the task internally on both `RunAsync` and `RunStreamingAsync` invocations.|
## Running the samples from the console
@@ -35,7 +35,7 @@
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="$(AgentFrameworkVersion)" />
<PackageReference Include="Azure.AI.Projects" Version="2.1.0-beta.4" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="ModelContextProtocol" Version="2.1.0" />
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
<PackageReference Include="DotNetEnv" Version="3.1.1" />
</ItemGroup>
@@ -1,22 +0,0 @@
# 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-*/
@@ -1,9 +0,0 @@
# 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
@@ -1,40 +0,0 @@
<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>
@@ -1,40 +0,0 @@
// 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))!;
@@ -1,76 +0,0 @@
# 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.
@@ -1,36 +0,0 @@
# 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
@@ -36,7 +36,7 @@
<PackageReference Include="Microsoft.Agents.AI.Mcp" Version="1.15.0-alpha.260722.1" />
<PackageReference Include="Azure.AI.Projects" Version="2.1.0-beta.4" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="ModelContextProtocol" Version="2.1.0" />
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
<PackageReference Include="DotNetEnv" Version="3.1.1" />
</ItemGroup>
@@ -25,7 +25,7 @@
<PackageReference Include="Microsoft.Agents.AI.Hosting" Version="$(AgentFrameworkVersion)" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="ModelContextProtocol" Version="2.1.0" />
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
<PackageReference Include="DotNetEnv" Version="3.1.1" />
</ItemGroup>
@@ -1,22 +0,0 @@
# 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-*/
@@ -1,5 +0,0 @@
# Optional local countdown delay
COUNTDOWN_DELAY_SECONDS=1
# Local development only
ASPNETCORE_URLS=http://+:8088
@@ -1,38 +0,0 @@
<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>
@@ -1,137 +0,0 @@
// 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);
}
@@ -1,117 +0,0 @@
# 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.
@@ -1,37 +0,0 @@
# 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
@@ -1,22 +0,0 @@
# 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-*/
@@ -1,9 +0,0 @@
# 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
@@ -1,40 +0,0 @@
<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>
@@ -1,63 +0,0 @@
// 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))!;
@@ -1,112 +0,0 @@
# 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.
@@ -1,36 +0,0 @@
# 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
@@ -40,26 +40,10 @@ IChatClient chatClient = new AIProjectClient(new Uri(endpoint), new DefaultAzure
.GetChatClient(deploymentName)
.AsIChatClient();
// A workflow checkpoint records each executor identity. Keep both Id and Name stable so a new
// container instance reconstructs the same workflow and can resume checkpoints written earlier.
AIAgent frenchAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Id = "french-translator",
Name = "French Translator",
ChatOptions = new() { Instructions = "You are a translation assistant that translates the provided text to French." },
});
AIAgent spanishAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Id = "spanish-translator",
Name = "Spanish Translator",
ChatOptions = new() { Instructions = "You are a translation assistant that translates the provided text to Spanish." },
});
AIAgent englishAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Id = "english-translator",
Name = "English Translator",
ChatOptions = new() { Instructions = "You are a translation assistant that translates the provided text to English." },
});
// Create translation agents
AIAgent frenchAgent = chatClient.AsAIAgent("You are a translation assistant that translates the provided text to French.");
AIAgent spanishAgent = chatClient.AsAIAgent("You are a translation assistant that translates the provided text to Spanish.");
AIAgent englishAgent = chatClient.AsAIAgent("You are a translation assistant that translates the provided text to English.");
// Build the sequential workflow: French → Spanish → English
AIAgent agent = new WorkflowBuilder(frenchAgent)
@@ -205,10 +205,4 @@ 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).
## 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.
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).
@@ -12,7 +12,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Core" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
@@ -1,91 +0,0 @@
// 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);
}
@@ -1,971 +0,0 @@
// 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;
}
}
@@ -1,103 +0,0 @@
# 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`.
@@ -1,26 +0,0 @@
<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,9 +48,6 @@ 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
@@ -12,7 +12,6 @@
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AzureStorage\Microsoft.Agents.AI.Hosting.AzureStorage.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
@@ -3,7 +3,6 @@
using AgentWebChat.AgentHost;
using AgentWebChat.AgentHost.Custom;
using AgentWebChat.AgentHost.Utilities;
using Azure.Storage.Blobs;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DevUI;
using Microsoft.Agents.AI.Hosting;
@@ -44,26 +43,8 @@ var pirateAgentBuilder = builder.AddAIAgent(
description: "An agent that speaks like a pirate.",
chatClientServiceKey: "chat-model")
.WithAITool(new CustomAITool())
.WithAITool(new CustomFunctionTool());
// Set both environment variables to replace development-only in-memory storage with Azure Blob Storage.
string? blobConnectionString = Environment.GetEnvironmentVariable("AZURE_STORAGE_BLOB_CONNECTION_STRING");
if (string.IsNullOrWhiteSpace(blobConnectionString))
{
pirateAgentBuilder.WithInMemorySessionStore();
}
else
{
string? blobContainerName = Environment.GetEnvironmentVariable("AZURE_STORAGE_BLOB_CONTAINER_NAME");
if (string.IsNullOrWhiteSpace(blobContainerName))
{
throw new InvalidOperationException(
"AZURE_STORAGE_BLOB_CONTAINER_NAME must be set when AZURE_STORAGE_BLOB_CONNECTION_STRING is configured.");
}
BlobContainerClient containerClient = new(blobConnectionString, blobContainerName);
pirateAgentBuilder.WithAzureBlobSessionStore(containerClient);
}
.WithAITool(new CustomFunctionTool())
.WithInMemorySessionStore();
var knightsKnavesAgentBuilder = builder.AddAIAgent("knights-and-knaves", (sp, key) =>
{
@@ -114,7 +114,6 @@ public sealed class A2AAgent : AIAgent
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
A2AAgentSession typedSession = await this.GetA2ASessionAsync(session, options, cancellationToken).ConfigureAwait(false);
FeatureUsageMarker.MarkUsed();
this._logger.LogA2AAgentInvokingAgent(nameof(RunAsync), this.Id, this.Name);
@@ -167,7 +166,6 @@ public sealed class A2AAgent : AIAgent
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
A2AAgentSession typedSession = await this.GetA2ASessionAsync(session, options, cancellationToken).ConfigureAwait(false);
FeatureUsageMarker.MarkUsed();
this._logger.LogA2AAgentInvokingAgent(nameof(RunStreamingAsync), this.Id, this.Name);
@@ -1,18 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.A2A;
internal enum FeatureIndex
{
A2A = 62,
}
internal static class FeatureUsageMarker
{
public static void MarkUsed()
{
#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
FeatureUsage.MarkUsed((int)FeatureIndex.A2A);
#pragma warning restore MAAI001
}
}
@@ -1,8 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI;
internal enum FeatureIndex
{
CoreInMemoryHistoryProvider = 13,
}
@@ -1,284 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Threading;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides process-wide tracking for Agent Framework feature usage.
/// </summary>
/// <remarks>
/// This type supports framework integrations and is not intended for direct use by applications.
/// Feature usage is accumulated for the lifetime of the process and does not represent invocation counts.
/// </remarks>
[EditorBrowsable(EditorBrowsableState.Never)]
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static class FeatureUsage
{
private const string FeatureMaskDisabledEnvironmentVariable = "AGENT_FRAMEWORK_FEATURE_MASK_DISABLED";
private const int RegistryVersion = 1;
private static long s_low;
private static long s_high;
private static bool s_isDisabled = ReadDisabledState();
private static TokenCache? s_cachedToken;
/// <summary>
/// Marks a registered Agent Framework feature as used in the current process.
/// </summary>
/// <param name="index">The zero-based feature index in the range 0 through 127.</param>
/// <exception cref="ArgumentOutOfRangeException">
/// <paramref name="index"/> is outside the range 0 through 127 and feature-usage tracking is enabled.
/// </exception>
/// <remarks>
/// Marking is idempotent. A feature bit remains set for the lifetime of the process.
/// When <c>AGENT_FRAMEWORK_FEATURE_MASK_DISABLED</c> is set to <c>true</c> or <c>1</c>,
/// marking is disabled and this method is a no-op.
/// </remarks>
public static void MarkUsed(int index)
{
if (Volatile.Read(ref s_isDisabled))
{
return;
}
if ((uint)index >= 128)
{
throw new ArgumentOutOfRangeException(nameof(index), index, "Feature index must be in the range 0 through 127.");
}
long bit = 1L << (index & 63);
if (index < 64)
{
AtomicOr(ref s_low, bit);
}
else
{
AtomicOr(ref s_high, bit);
}
}
/// <summary>
/// Applies the current Agent Framework feature-usage token to a User-Agent value.
/// </summary>
/// <param name="userAgent">The existing User-Agent value.</param>
/// <param name="includeFeatureToken">
/// <see langword="true"/> to append or refresh the current token; <see langword="false"/> to remove any existing
/// Agent Framework feature token.
/// </param>
/// <returns>
/// The supplied User-Agent with at most one current <c>(feat=vN.hex)</c> comment, or with the feature comment
/// removed when the token is disabled, empty, or excluded.
/// </returns>
/// <remarks>
/// This infrastructure method does not approve a destination and does not sanitize the supplied User-Agent.
/// Callers must independently verify that the actual request destination is approved before including the token.
/// </remarks>
public static string ApplyToUserAgent(string userAgent, bool includeFeatureToken = true)
{
if (userAgent is null)
{
throw new ArgumentNullException(nameof(userAgent));
}
string baseUserAgent = RemoveFeatureComments(userAgent);
string? token = includeFeatureToken ? GetToken() : null;
if (token is null)
{
return baseUserAgent;
}
return baseUserAgent.Length == 0
? $"(feat={token})"
: $"{baseUserAgent} (feat={token})";
}
internal static string? GetToken()
{
if (Volatile.Read(ref s_isDisabled))
{
return null;
}
long low = Volatile.Read(ref s_low);
long high = Volatile.Read(ref s_high);
if (low == 0 && high == 0)
{
return null;
}
TokenCache? cached = Volatile.Read(ref s_cachedToken);
if (cached is not null && low == cached.Low && high == cached.High)
{
return cached.Token;
}
string token = high == 0
? $"v{RegistryVersion}.{(ulong)low:x}"
: $"v{RegistryVersion}.{(ulong)high:x}{(ulong)low:x16}";
Volatile.Write(ref s_cachedToken, new TokenCache(low, high, token));
return token;
}
/// <summary>
/// Resets the process-global feature-usage state to isolate tests.
/// </summary>
/// <remarks>
/// This test-only hook must not be used by production paths; production feature state is monotonic and never resets.
/// </remarks>
internal static void ResetStateForTests()
{
_ = Interlocked.Exchange(ref s_low, 0);
_ = Interlocked.Exchange(ref s_high, 0);
Volatile.Write(ref s_cachedToken, null);
Volatile.Write(ref s_isDisabled, ReadDisabledState());
}
/// <summary>
/// Reloads the cached mask-disabled environment setting without resetting the feature mask.
/// </summary>
/// <remarks>
/// This test-only hook verifies startup-cached configuration behavior without clearing observed feature state.
/// Production paths read the setting once when this type initializes.
/// </remarks>
internal static void ReloadDisabledStateForTests()
=> Volatile.Write(ref s_isDisabled, ReadDisabledState());
private static void AtomicOr(ref long location, long value)
{
if ((Volatile.Read(ref location) & value) != 0)
{
return;
}
#if NETSTANDARD2_0 || NETFRAMEWORK
long current;
long updated;
do
{
current = Volatile.Read(ref location);
updated = current | value;
if (current == updated)
{
return;
}
}
while (Interlocked.CompareExchange(ref location, updated, current) != current);
#else
_ = Interlocked.Or(ref location, value);
#endif
}
private static bool ReadDisabledState()
{
string? value = Environment.GetEnvironmentVariable(FeatureMaskDisabledEnvironmentVariable);
return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, "1", StringComparison.OrdinalIgnoreCase);
}
private static string RemoveFeatureComments(string userAgent)
{
if (!TryFindFeatureComment(userAgent, searchFrom: 0, out int commentStart, out int commentEnd))
{
return userAgent;
}
var result = new StringBuilder(userAgent.Length);
int copyFrom = 0;
do
{
int removeFrom = commentStart;
int removeThrough = commentEnd;
if (removeFrom > copyFrom && char.IsWhiteSpace(userAgent[removeFrom - 1]))
{
removeFrom--;
}
else if (removeFrom == copyFrom &&
removeThrough < userAgent.Length &&
char.IsWhiteSpace(userAgent[removeThrough]))
{
removeThrough++;
}
result.Append(userAgent, copyFrom, removeFrom - copyFrom);
copyFrom = removeThrough;
}
while (TryFindFeatureComment(userAgent, commentEnd, out commentStart, out commentEnd));
result.Append(userAgent, copyFrom, userAgent.Length - copyFrom);
return result.ToString();
}
private static bool TryFindFeatureComment(string userAgent, int searchFrom, out int start, out int end)
{
const string Prefix = "(feat=v";
while ((start = userAgent.IndexOf(Prefix, searchFrom, StringComparison.Ordinal)) >= 0)
{
if (start > 0 && !char.IsWhiteSpace(userAgent[start - 1]))
{
searchFrom = start + Prefix.Length;
continue;
}
int cursor = start + Prefix.Length;
int versionStart = cursor;
while (cursor < userAgent.Length && userAgent[cursor] is >= '0' and <= '9')
{
cursor++;
}
if (cursor == versionStart || cursor >= userAgent.Length || userAgent[cursor] != '.')
{
searchFrom = start + Prefix.Length;
continue;
}
cursor++;
int maskStart = cursor;
while (cursor < userAgent.Length && IsHexDigit(userAgent[cursor]))
{
cursor++;
}
if (cursor == maskStart || cursor >= userAgent.Length || userAgent[cursor] != ')')
{
searchFrom = start + Prefix.Length;
continue;
}
end = cursor + 1;
if (end == userAgent.Length || char.IsWhiteSpace(userAgent[end]))
{
return true;
}
searchFrom = start + Prefix.Length;
}
start = -1;
end = -1;
return false;
}
private static bool IsHexDigit(char value)
=> value is >= '0' and <= '9'
or >= 'a' and <= 'f'
or >= 'A' and <= 'F';
private sealed class TokenCache(long low, long high, string token)
{
public long Low { get; } = low;
public long High { get; } = high;
public string Token { get; } = token;
}
}
@@ -88,10 +88,6 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
/// <inheritdoc />
protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
#pragma warning disable MAAI001
FeatureUsage.MarkUsed((int)FeatureIndex.CoreInMemoryHistoryProvider);
#pragma warning restore MAAI001
State state = this._sessionState.GetOrInitializeState(context.Session);
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null)
@@ -106,10 +102,6 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
/// <inheritdoc />
protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
#pragma warning disable MAAI001
FeatureUsage.MarkUsed((int)FeatureIndex.CoreInMemoryHistoryProvider);
#pragma warning restore MAAI001
State state = this._sessionState.GetOrInitializeState(context.Session);
// Add request and response messages to the provider
@@ -68,7 +68,6 @@ public static class AnthropicBetaServiceExtensions
chatClient = clientFactory(chatClient);
}
chatClient = new Microsoft.Agents.AI.Anthropic.FeatureUsageChatClient(chatClient);
return new ChatClientAgent(chatClient, options, loggerFactory, services);
}
@@ -99,7 +98,6 @@ public static class AnthropicBetaServiceExtensions
chatClient = clientFactory(chatClient);
}
chatClient = new Microsoft.Agents.AI.Anthropic.FeatureUsageChatClient(chatClient);
return new ChatClientAgent(chatClient, options, loggerFactory, services);
}
}
@@ -68,7 +68,6 @@ public static class AnthropicClientExtensions
chatClient = clientFactory(chatClient);
}
chatClient = new Microsoft.Agents.AI.Anthropic.FeatureUsageChatClient(chatClient);
return new ChatClientAgent(chatClient, options, loggerFactory, services);
}
@@ -99,7 +98,6 @@ public static class AnthropicClientExtensions
chatClient = clientFactory(chatClient);
}
chatClient = new Microsoft.Agents.AI.Anthropic.FeatureUsageChatClient(chatClient);
return new ChatClientAgent(chatClient, options, loggerFactory, services);
}
}
@@ -1,45 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Runtime.CompilerServices;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Anthropic;
internal enum FeatureIndex
{
Anthropic = 55,
}
internal static class FeatureUsageMarker
{
public static void MarkUsed()
{
#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
FeatureUsage.MarkUsed((int)FeatureIndex.Anthropic);
#pragma warning restore MAAI001
}
}
internal sealed class FeatureUsageChatClient(IChatClient innerClient) : DelegatingChatClient(innerClient)
{
public override Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
FeatureUsageMarker.MarkUsed();
return base.GetResponseAsync(messages, options, cancellationToken);
}
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
FeatureUsageMarker.MarkUsed();
await foreach (ChatResponseUpdate update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
}
}
@@ -88,7 +88,6 @@ public class CopilotStudioAgent : AIAgent
throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(CopilotStudioAgentSession)}' can be used by this agent.");
}
FeatureUsageMarker.MarkUsed();
typedSession.ConversationId ??= await this.StartNewConversationAsync(cancellationToken).ConfigureAwait(false);
// Invoke the Copilot Studio agent with the provided messages.
@@ -121,7 +120,6 @@ public class CopilotStudioAgent : AIAgent
throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(CopilotStudioAgentSession)}' can be used by this agent.");
}
FeatureUsageMarker.MarkUsed();
typedSession.ConversationId ??= await this.StartNewConversationAsync(cancellationToken).ConfigureAwait(false);
// Invoke the Copilot Studio agent with the provided messages.
@@ -1,18 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.CopilotStudio;
internal enum FeatureIndex
{
CopilotStudio = 56,
}
internal static class FeatureUsageMarker
{
public static void MarkUsed()
{
#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
FeatureUsage.MarkUsed((int)FeatureIndex.CopilotStudio);
#pragma warning restore MAAI001
}
}
@@ -243,7 +243,6 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
}
#pragma warning restore CA1513
FeatureUsageMarker.MarkUsed();
var state = this._sessionState.GetOrInitializeState(session);
var partitionKey = BuildPartitionKey(state);
@@ -307,7 +306,6 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
}
#pragma warning restore CA1513
FeatureUsageMarker.MarkUsed();
var state = this._sessionState.GetOrInitializeState(context.Session);
var messageList = context.RequestMessages.Concat(context.ResponseMessages ?? []).ToList();
if (messageList.Count == 0)
@@ -479,7 +477,6 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
}
#pragma warning restore CA1513
FeatureUsageMarker.MarkUsed();
var state = this._sessionState.GetOrInitializeState(session);
var partitionKey = BuildPartitionKey(state);
@@ -514,7 +511,6 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
}
#pragma warning restore CA1513
FeatureUsageMarker.MarkUsed();
var state = this._sessionState.GetOrInitializeState(session);
var partitionKey = BuildPartitionKey(state);
@@ -109,7 +109,6 @@ public class CosmosCheckpointStore<T> : JsonCheckpointStore, IDisposable
}
#pragma warning restore CA1513
FeatureUsageMarker.MarkUsed();
var checkpointId = Guid.NewGuid().ToString("N");
var checkpointInfo = new CheckpointInfo(sessionId, checkpointId);
@@ -147,7 +146,6 @@ public class CosmosCheckpointStore<T> : JsonCheckpointStore, IDisposable
}
#pragma warning restore CA1513
FeatureUsageMarker.MarkUsed();
var id = $"{sessionId}_{key.CheckpointId}";
try
@@ -177,7 +175,6 @@ public class CosmosCheckpointStore<T> : JsonCheckpointStore, IDisposable
}
#pragma warning restore CA1513
FeatureUsageMarker.MarkUsed();
QueryDefinition query = withParent == null
? new QueryDefinition("SELECT c.sessionId, c.checkpointId FROM c WHERE c.sessionId = @sessionId ORDER BY c.timestamp ASC")
.WithParameter("@sessionId", sessionId)
@@ -1,18 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.CosmosNoSql;
internal enum FeatureIndex
{
AzureCosmos = 58,
}
internal static class FeatureUsageMarker
{
public static void MarkUsed()
{
#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
FeatureUsage.MarkUsed((int)FeatureIndex.AzureCosmos);
#pragma warning restore MAAI001
}
}
@@ -41,7 +41,6 @@ public sealed class AggregatorPromptAgentFactory : PromptAgentFactory
var agent = await agentFactory.TryCreateAsync(promptAgent, cancellationToken).ConfigureAwait(false);
if (agent is not null)
{
Declarative.FeatureUsageMarker.MarkUsed();
return agent;
}
}
@@ -43,7 +43,6 @@ public sealed class ChatClientPromptAgentFactory : PromptAgentFactory
var agent = new ChatClientAgent(this._chatClient, options, this._loggerFactory);
Declarative.FeatureUsageMarker.MarkUsed();
return Task.FromResult<AIAgent?>(agent);
}
@@ -1,18 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Declarative;
internal enum FeatureIndex
{
DeclarativeAgent = 65,
}
internal static class FeatureUsageMarker
{
public static void MarkUsed()
{
#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
FeatureUsage.MarkUsed((int)FeatureIndex.DeclarativeAgent);
#pragma warning restore MAAI001
}
}
@@ -49,9 +49,8 @@ public abstract class PromptAgentFactory
{
Throw.IfNull(promptAgent);
var agent = await this.TryCreateAsync(promptAgent, cancellationToken).ConfigureAwait(false) ?? throw new NotSupportedException($"Agent type {promptAgent.Kind} is not supported.");
Declarative.FeatureUsageMarker.MarkUsed();
return agent;
var agent = await this.TryCreateAsync(promptAgent, cancellationToken).ConfigureAwait(false);
return agent ?? throw new NotSupportedException($"Agent type {promptAgent.Kind} is not supported.");
}
/// <summary>
@@ -59,7 +59,6 @@ public static class DevUIExtensions
protectedGroup.MapDevUI(pattern: "/devui");
protectedGroup.MapEntities();
FeatureUsageMarker.MarkUsed();
return protectedGroup;
}
@@ -1,18 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DevUI;
internal enum FeatureIndex
{
DevUI = 64,
}
internal static class FeatureUsageMarker
{
public static void MarkUsed()
{
#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
FeatureUsage.MarkUsed((int)FeatureIndex.DevUI);
#pragma warning restore MAAI001
}
}
@@ -6,16 +6,13 @@ 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;
using Microsoft.Extensions.Options;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
// The terminal stream events are named the same in two namespaces this file pulls in, and the short
// name binds to the one the event objects are not. Naming them here keeps `is` checks against the
@@ -23,7 +20,6 @@ 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;
@@ -35,19 +31,10 @@ 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.
@@ -65,39 +52,13 @@ 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);
ArgumentNullException.ThrowIfNull(serviceProvider);
ArgumentNullException.ThrowIfNull(logger);
this._serviceProvider = serviceProvider;
this._logger = logger;
this._toolboxService = toolboxService;
this._resilientBackground = foundryResponsesOptions.Value.ResilientBackground;
}
/// <inheritdoc/>
@@ -110,6 +71,20 @@ public class AgentFrameworkResponseHandler : ResponseHandler
var agent = this.ResolveAgent(request);
var sessionStore = this.ResolveSessionStore(request);
// Fail fast with a clear, actionable error when this 2.0.0-only image is served container
// protocol 1.0.0. The x-agent-foundry-call-id header is exclusive to protocol 2.0.0, so when the
// container is hosted by Foundry yet receives no call id, the platform is talking 1.0.0 to an
// image that does not support it. Detecting this here turns an opaque 500 into a 501 that names
// the cause and the fix instead of bubbling up as a generic server error on every request.
var unsupportedProtocolError = HostedProtocolCompatibility.GetUnsupportedProtocolError(
FoundryEnvironment.IsHosted, context.PlatformContext?.CallId);
if (unsupportedProtocolError is not null)
{
this._logger.LogError(
"Hosted container served unsupported Responses protocol 1.0.0 (no x-agent-foundry-call-id header); this image requires protocol 2.0.0.");
throw unsupportedProtocolError;
}
// 2. Resolve the per-request hosted session identity context, so the session can be
// loaded from a per-user partition. Fresh sessions are tagged once; resumed sessions are
// validated against the live request to detect cross-user session leaks and in-process tampering.
@@ -118,8 +93,8 @@ public class AgentFrameworkResponseHandler : ResponseHandler
var resolvedHostedContext = await isolationKeyProvider.GetKeysAsync(context, request, cancellationToken).ConfigureAwait(false);
if (resolvedHostedContext is null && FoundryEnvironment.IsHosted)
{
// Hosted by Foundry yet the provider produced no user identity. The endpoint filter
// already rejected protocol 1.0.0, so this is the unexpected case of a 2.0.0
// Hosted by Foundry yet the provider produced no user identity. Protocol 1.0.0 (no call id)
// was already turned into a clear 501 above, so this is the unexpected case of a 2.0.0
// request that carried a call id but no x-agent-user-id, or a custom provider that returned
// null in production. Reject rather than silently persist an unscoped, cross-user session.
throw new InvalidOperationException(
@@ -155,23 +130,9 @@ public class AgentFrameworkResponseHandler : ResponseHandler
// Load the session for this conversation, or start a new one. The store returns null when
// 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);
}
else
{
session = await sessionStore.GetSessionAsync(
agent,
agentSessionId,
resolvedUserId,
cancellationToken).ConfigureAwait(false);
sessionRestoredFromStore = session is not null;
session ??= await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
}
AgentSession? session = !string.IsNullOrWhiteSpace(agentSessionId)
? await sessionStore.GetOrCreateSessionAsync(agent, agentSessionId, resolvedUserId, cancellationToken).ConfigureAwait(false)
: await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
// Capture the platform per-request call id (x-agent-foundry-call-id, protocol 2.0.0 only).
// It is re-applied to the ambient HostedCallContext immediately before each outbound egress
@@ -203,31 +164,8 @@ public class AgentFrameworkResponseHandler : ResponseHandler
}
}
// 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. Create the SDK event stream builder
var stream = new ResponseEventStream(context, request);
// 3. Emit lifecycle events
yield return stream.EmitCreated();
@@ -235,26 +173,18 @@ 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>();
if (shouldInjectRequestInput)
// Load and convert current input items
var inputItems = await context.GetInputItemsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
if (inputItems.Count > 0)
{
// 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));
}
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
@@ -408,13 +338,9 @@ 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, 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.
// 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.
var useVolatileChatHistoryProvider =
shouldInjectRequestInput
&& !allowStoredOutputEnabled
!allowStoredOutputEnabled
&& agent.GetService<ChatClientAgent>() is not null
&& agentOptions?.ChatHistoryProvider is null;
@@ -433,22 +359,13 @@ 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,
context.Shutdown);
using var consentCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
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;
@@ -459,49 +376,6 @@ 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() =>
@@ -513,8 +387,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
agent.RunStreamingAsync(messages, session, options: options, cancellationToken: consentCts.Token),
stream,
session?.StateBag,
persistWorkflowCheckpointHandler: PersistWorkflowCheckpointAsync,
cancellationToken: cancellationToken).GetAsyncEnumerator(cancellationToken);
cancellationToken).GetAsyncEnumerator(cancellationToken);
try
{
while (true)
@@ -536,8 +409,6 @@ public class AgentFrameworkResponseHandler : ResponseHandler
}
evt = enumerator.Current;
shutdownDetected =
context.IsShutdownRequested && !emittedTerminal;
}
catch (OperationCanceledException) when (!emittedTerminal && consentState.Pending is not null)
{
@@ -548,10 +419,6 @@ 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
@@ -601,40 +468,12 @@ public class AgentFrameworkResponseHandler : ResponseHandler
if (shutdownDetected)
{
// 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;
}
// Server is shutting down — emit incomplete so clients can resume
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
@@ -646,41 +485,9 @@ 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;
@@ -698,16 +505,10 @@ public class AgentFrameworkResponseHandler : ResponseHandler
turnFailed = true;
}
// 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)
// Persist the session for the next turn of this conversation, unless this one is being failed.
if (session is not null && !turnFailed)
{
await sessionStore.SaveSessionAsync(
agent,
agentSessionId!,
session,
resolvedUserId,
steeringDetected ? CancellationToken.None : cancellationToken).ConfigureAwait(false);
await sessionStore.SaveSessionAsync(agent, agentSessionId!, session, resolvedUserId, cancellationToken).ConfigureAwait(false);
}
}
@@ -768,16 +569,6 @@ 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>.
@@ -792,11 +583,8 @@ public class AgentFrameworkResponseHandler : ResponseHandler
var agent = this._serviceProvider.GetKeyedService<AIAgent>(agentName);
if (agent is not null)
{
string storageIdentity = FoundryHostingAgent.ResolveSessionStorageIdentity(
agent,
agentName,
this._serviceProvider.GetService<AIAgent>());
return this.PrepareResolvedAgent(agent, storageIdentity);
FoundryHostingExtensions.TryApplyUserAgent(agent);
return FoundryHostingExtensions.ApplyOpenTelemetry(agent);
}
if (this._logger.IsEnabled(LogLevel.Warning))
@@ -809,11 +597,8 @@ public class AgentFrameworkResponseHandler : ResponseHandler
var defaultAgent = this._serviceProvider.GetService<AIAgent>();
if (defaultAgent is not null)
{
string storageIdentity = FoundryHostingAgent.ResolveSessionStorageIdentity(
defaultAgent,
registrationKey: null,
defaultAgent);
return this.PrepareResolvedAgent(defaultAgent, storageIdentity);
FoundryHostingExtensions.TryApplyUserAgent(defaultAgent);
return FoundryHostingExtensions.ApplyOpenTelemetry(defaultAgent);
}
var errorMessage = string.IsNullOrEmpty(agentName)
@@ -823,18 +608,6 @@ public class AgentFrameworkResponseHandler : ResponseHandler
throw new InvalidOperationException(errorMessage);
}
private AIAgent PrepareResolvedAgent(AIAgent agent, string sessionStorageIdentity)
{
FoundryHostingExtensions.TryApplyUserAgent(agent);
AIAgent prepared = FoundryHostingExtensions.ApplyWorkflowCheckpointing(
agent,
this._serviceProvider.GetService<ILoggerFactory>());
prepared = new FoundryHostingAgent(prepared, sessionStorageIdentity);
return FoundryHostingExtensions.ApplyOpenTelemetry(prepared);
}
/// <summary>
/// Resolves an <see cref="AIAgent"/> from the request.
/// Tries <c>agent.name</c> first, then falls back to <c>metadata["entity_id"]</c>.
@@ -1,8 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Foundry.Hosting;
internal enum FeatureIndex
{
FoundryHosting = 53,
}
@@ -1,247 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Core.Storage;
using Azure.Core;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Provides an <see cref="AgentSessionStore"/> that persists the agent-framework's serialized
/// <see cref="AgentSession"/> state through <see cref="FoundryStateStore"/>.
/// </summary>
/// <remarks>
/// <para>
/// The AgentServer SDK selects the backend. In Foundry hosting it writes to the platform's durable
/// state store, so a session survives container replacement and is visible to every instance of the
/// agent. Outside Foundry hosting it uses the SDK's local state-store fallback under
/// <c>~/.agentserver/state_stores</c>.
/// </para>
/// <para>
/// Layout. All sessions live in one state store, named <see cref="DefaultStoreName"/> unless
/// overridden, and each (agent, user, conversation) triple is one item in it. The item key is a
/// hash of an unambiguous, length-prefixed encoding of the hosted registration identity, user id,
/// and conversation id. Hashing is required because the platform limits an item key to 128
/// characters. The readable encoding is stored alongside the session so an item can still be traced
/// back to its partition.
/// </para>
/// <para>
/// Per-user isolation is expressed through the item key rather than through the state store's own
/// <c>userIsolation</c> option. That option is fixed when the store is created and resolves the
/// user from the calling identity, whereas the user id handled here arrives per request and the
/// container always calls the storage API with its own identity.
/// </para>
/// <para>
/// The bound state store is resolved once, on first use, and reused for the lifetime of this
/// instance. Resolving it costs one round trip (plus one more the very first time, to create the
/// store), so it deliberately does not happen per request.
/// </para>
/// <para>
/// Direct callers must provide an agent with a stable <see cref="AIAgent.Name"/>. The Foundry
/// response handler supplies keyed and default registration identities explicitly, including for
/// unnamed agents.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FoundryAgentSessionStore : AgentSessionStore
{
/// <summary>
/// The default state-store name used to hold every agent session persisted by this store.
/// </summary>
public const string DefaultStoreName = "agent-framework/sessions";
/// <summary>The item-body field holding the serialized session JSON.</summary>
private const string SessionField = "session";
/// <summary>The item-body field holding the readable logical key, for traceability.</summary>
private const string KeyField = "key";
private readonly FoundryStateStoreBinding _binding;
/// <summary>
/// Initializes a new instance of the <see cref="FoundryAgentSessionStore"/> class.
/// </summary>
/// <param name="endpoint">
/// The Foundry project endpoint. Used only in Foundry hosting. When <see langword="null"/>,
/// it is read from the <c>FOUNDRY_PROJECT_ENDPOINT</c> environment variable. Outside Foundry
/// hosting, the AgentServer SDK ignores it and uses its local state-store fallback.
/// </param>
/// <param name="credential">
/// The credential used to authenticate to the Foundry storage API. May be <see langword="null"/>
/// outside Foundry hosting, where the AgentServer SDK uses its local state-store fallback.
/// </param>
/// <param name="storeName">The state-store name to hold the sessions. Defaults to <see cref="DefaultStoreName"/>.</param>
/// <param name="itemTtlSeconds">
/// How long a session survives without being written, in seconds. Defaults to the platform
/// default of 30 days; <c>-1</c> means never expire. A write renews the window, a read does
/// not. The value only takes effect when this store is created for the first time, because the
/// platform fixes it at creation.
/// </param>
public FoundryAgentSessionStore(
Uri? endpoint = null,
TokenCredential? credential = null,
string storeName = DefaultStoreName,
int itemTtlSeconds = FoundryStateStore.DefaultItemTtlSeconds)
{
_ = Throw.IfNullOrWhitespace(storeName);
this.StoreName = storeName;
this._binding = new(cancellationToken => FoundryStateStore.GetOrCreateAsync(
storeName,
credential,
endpoint,
description: "Agent Framework hosted agent sessions.",
itemTtlSeconds: itemTtlSeconds,
cancellationToken: cancellationToken));
}
/// <summary>
/// Initializes a new instance of the <see cref="FoundryAgentSessionStore"/> class over a
/// caller-supplied state store. Used by tests to substitute the platform client.
/// </summary>
/// <param name="storeFactory">Resolves the bound state store on first use.</param>
/// <param name="storeName">The state-store name, for diagnostics.</param>
internal FoundryAgentSessionStore(Func<CancellationToken, Task<FoundryStateStore>> storeFactory, string storeName = DefaultStoreName)
{
_ = Throw.IfNull(storeFactory);
this._binding = new(storeFactory);
this.StoreName = storeName;
}
/// <summary>Gets the state-store name that holds the sessions.</summary>
public string StoreName { get; }
/// <inheritdoc/>
public override async ValueTask SaveSessionAsync(
AIAgent agent,
string conversationId,
AgentSession session,
string? userId,
CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(agent);
_ = Throw.IfNullOrWhitespace(conversationId);
_ = Throw.IfNull(session);
string agentIdentity = ResolveAgentIdentity(agent);
JsonElement serialized = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false);
BinaryData sessionData = ToBinaryData(serialized);
string logicalKey = BuildLogicalKey(agentIdentity, conversationId, userId);
FoundryStateStore store = await this.GetStoreAsync(cancellationToken).ConfigureAwait(false);
await store.SetItemAsync(
BuildItemKey(logicalKey),
new Dictionary<string, BinaryData>
{
[SessionField] = sessionData,
[KeyField] = ToJsonString(logicalKey),
},
cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public override async ValueTask<AgentSession?> GetSessionAsync(
AIAgent agent,
string conversationId,
string? userId,
CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(agent);
_ = Throw.IfNullOrWhitespace(conversationId);
string logicalKey = BuildLogicalKey(ResolveAgentIdentity(agent), conversationId, userId);
FoundryStateStore store = await this.GetStoreAsync(cancellationToken).ConfigureAwait(false);
// GetItemAsync already answers null for an item that is not there, which is exactly the
// "nothing stored" result this method contracts to return.
StateStoreItem? item = await store.GetItemAsync(BuildItemKey(logicalKey), cancellationToken).ConfigureAwait(false);
if (!FoundryStateStoreJson.TryGetField(item, SessionField, out BinaryData? sessionData))
{
return null;
}
ReadOnlyMemory<byte> bytes = sessionData.ToMemory();
// Parse and clone so the document buffer can be released.
using JsonDocument document = JsonDocument.Parse(bytes);
JsonElement element = document.RootElement.Clone();
return await agent.DeserializeSessionAsync(element, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Resolves the bound state store, creating it on the platform the first time. See
/// <see cref="FoundryStateStoreBinding"/> for the caching and failure behaviour.
/// </summary>
private ValueTask<FoundryStateStore> GetStoreAsync(CancellationToken cancellationToken)
=> this._binding.GetAsync(cancellationToken);
/// <summary>
/// Builds an unambiguous readable partition key from the hosted agent identity, end user, and
/// conversation. Each component carries its length so delimiters inside values cannot collide.
/// </summary>
internal static string BuildLogicalKey(string agentIdentity, string conversationId, string? userId)
{
StringBuilder builder = new();
AppendComponent(builder, 'a', Throw.IfNullOrWhitespace(agentIdentity));
AppendComponent(builder, 'u', string.IsNullOrWhiteSpace(userId) ? null : userId);
AppendComponent(builder, 'c', Throw.IfNullOrWhitespace(conversationId));
builder.Length--;
return builder.ToString();
}
private static string ResolveAgentIdentity(AIAgent agent)
{
_ = Throw.IfNull(agent);
if (agent.GetService<FoundryHostingAgent>() is { } hostingAgent)
{
return hostingAgent.SessionStorageIdentity;
}
if (string.IsNullOrWhiteSpace(agent.Name))
{
throw new InvalidOperationException(
$"Direct use of {nameof(FoundryAgentSessionStore)} requires an agent with a stable {nameof(AIAgent.Name)}. " +
"Foundry hosting supplies the keyed or default registration identity separately.");
}
return $"name:{agent.Name}";
}
private static void AppendComponent(StringBuilder builder, char prefix, string? value)
{
builder.Append(prefix).Append(value?.Length ?? -1).Append(':');
if (value is not null)
{
builder.Append(value);
}
builder.Append('|');
}
/// <summary>
/// Reduces a logical key to a fixed-length item key. The platform limits an item key to 128
/// characters, which an agent name plus a user id plus a conversation id can exceed, so the
/// logical key is hashed rather than truncated: truncation would let two different conversations
/// share a key and therefore overwrite each other's session.
/// </summary>
internal static string BuildItemKey(string logicalKey)
{
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(logicalKey));
return $"s-{Convert.ToBase64String(hash).TrimEnd('=').Replace('+', '-').Replace('/', '_')}";
}
private static BinaryData ToBinaryData(JsonElement element) => FoundryStateStoreJson.ToBinaryData(element);
private static BinaryData ToJsonString(string value) => FoundryStateStoreJson.ToJsonString(value);
}
@@ -1,54 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Carries Foundry hosting metadata alongside the agent served by the response handler.
/// </summary>
/// <remarks>
/// This wrapper is the central extension point for hosting-specific agent metadata. Outer agent
/// middleware continues to expose it through <see cref="AIAgent.GetService{TService}(object?)"/>.
/// </remarks>
internal sealed class FoundryHostingAgent : DelegatingAIAgent
{
internal FoundryHostingAgent(AIAgent innerAgent, string sessionStorageIdentity)
: base(innerAgent)
{
this.SessionStorageIdentity = Throw.IfNullOrWhitespace(sessionStorageIdentity);
}
internal string SessionStorageIdentity { get; }
/// <summary>
/// Resolves the stable identity used to partition session storage for the resolved agent.
/// </summary>
/// <remarks>
/// A keyed registration normally uses <c>key:{registrationKey}</c>. When keyed resolution
/// returns the same agent instance as the default registration, reference equality treats both
/// registrations as aliases and uses the default identity. This ensures named and unnamed
/// requests for the same agent share session state. The default identity uses
/// <c>name:{agent.Name}</c> when the agent has a name, or <c>default</c> otherwise.
/// </remarks>
/// <param name="agent">The agent resolved for the current request.</param>
/// <param name="registrationKey">The keyed registration requested by the caller, if any.</param>
/// <param name="defaultAgent">The agent registered as the default, if any.</param>
/// <returns>The stable session storage identity.</returns>
internal static string ResolveSessionStorageIdentity(
AIAgent agent,
string? registrationKey,
AIAgent? defaultAgent)
{
_ = Throw.IfNull(agent);
if (registrationKey is not null && !ReferenceEquals(agent, defaultAgent))
{
return $"key:{registrationKey}";
}
return !string.IsNullOrWhiteSpace(agent.Name)
? $"name:{agent.Name}"
: "default";
}
}
@@ -1,613 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Core.Storage;
using Azure.Core;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Provides a <see cref="JsonCheckpointStore"/> that persists workflow checkpoints through
/// <see cref="FoundryStateStore"/>.
/// </summary>
/// <remarks>
/// <para>
/// The AgentServer SDK selects the backend. In Foundry hosting it writes to the platform's durable
/// state store. Outside Foundry hosting it uses the SDK's local state-store fallback under
/// <c>~/.agentserver/state_stores</c>.
/// </para>
/// <para>
/// Item keys are hashes of the session identifier and the checkpoint identifier, because the
/// platform limits an item key to 128 characters and neither identifier is bounded. Hashing rather
/// than truncating means two different checkpoints can never end up sharing a key and overwriting
/// each other.
/// </para>
/// <para>
/// Retention. Retrieving a checkpoint happens when a workflow is resuming from it. At that point,
/// superseded ancestors and earlier entries without a parent are deleted. Sibling branches, the
/// resumed checkpoint, and later checkpoints are retained so another persisted or concurrent run
/// cannot lose its live state. Note that this makes <see cref="RetrieveCheckpointAsync"/> a write
/// operation as well as a read.
/// </para>
/// <para>
/// Concurrency. Adding a checkpoint writes the checkpoint item and then updates the session's index
/// item using the platform's optimistic concurrency check, retrying a bounded number of times when
/// another writer got there first. Two instances committing checkpoints for the same session at the
/// same time therefore do not lose entries.
/// </para>
/// <para>
/// This store partitions only by workflow session identifier, which is the only partition the
/// <see cref="ICheckpointStore{TStoreObject}"/> contract carries. It does not partition by end
/// user. Callers that serve several end users from one workflow session must keep user separation
/// in the session identifier itself.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FoundryJsonCheckpointStore : JsonCheckpointStore
{
/// <summary>
/// The default state-store name used to hold every workflow checkpoint persisted by this store.
/// </summary>
public const string DefaultStoreName = "agent-framework/checkpoints";
/// <summary>
/// The default number of attempts to update a workflow checkpoint index after concurrent writers
/// modify it.
/// </summary>
public const int DefaultMaxIndexUpdateAttempts = 8;
/// <summary>The item-body field holding the serialized checkpoint JSON.</summary>
private const string CheckpointField = "checkpoint";
/// <summary>The item-body field holding the owning session identifier, for traceability.</summary>
private const string SessionField = "session";
/// <summary>The item-body field of an index item holding the ordered checkpoint entries.</summary>
private const string EntriesField = "entries";
private const string EntryIdProperty = "id";
private const string EntryParentProperty = "parent";
private const string EntryHasParentProperty = "hasParent";
private readonly FoundryStateStoreBinding _binding;
private readonly ILogger? _logger;
private readonly int _maxIndexUpdateAttempts;
/// <summary>
/// Initializes a new instance of the <see cref="FoundryJsonCheckpointStore"/> class.
/// </summary>
/// <param name="endpoint">
/// The Foundry project endpoint. Used only in Foundry hosting. When <see langword="null"/>,
/// it is read from the <c>FOUNDRY_PROJECT_ENDPOINT</c> environment variable. Outside Foundry
/// hosting, the AgentServer SDK ignores it and uses its local state-store fallback.
/// </param>
/// <param name="credential">
/// The credential used to authenticate to the Foundry storage API. May be <see langword="null"/>
/// outside Foundry hosting, where the AgentServer SDK uses its local state-store fallback.
/// </param>
/// <param name="storeName">The state-store name to hold the checkpoints. Defaults to <see cref="DefaultStoreName"/>.</param>
/// <param name="itemTtlSeconds">
/// How long a checkpoint survives without being written, in seconds. Defaults to the platform
/// default of 30 days; <c>-1</c> means never expire. A write renews the window, a read does
/// not. The value only takes effect when this store is created for the first time, because the
/// platform fixes it at creation.
/// </param>
/// <param name="loggerFactory">
/// Creates the logger this store reports through. Optional, but without one a failure to clean
/// up old checkpoints leaves no trace, since it is deliberately not allowed to fail the call it
/// happens in.
/// </param>
public FoundryJsonCheckpointStore(
Uri? endpoint = null,
TokenCredential? credential = null,
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,
credential,
endpoint,
description: "Agent Framework hosted workflow checkpoints.",
itemTtlSeconds: itemTtlSeconds,
cancellationToken: cancellationToken));
}
/// <summary>
/// Initializes a new instance of the <see cref="FoundryJsonCheckpointStore"/> class over a
/// caller-supplied state store. Used by tests to substitute the platform client.
/// </summary>
/// <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,
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>();
}
/// <summary>Gets the state-store name that holds the checkpoints.</summary>
public string StoreName { get; }
/// <inheritdoc/>
public override async ValueTask<CheckpointInfo> CreateCheckpointAsync(string sessionId, JsonElement value, CheckpointInfo? parent = null)
{
_ = Throw.IfNullOrWhitespace(sessionId);
BinaryData checkpointData = FoundryStateStoreJson.ToBinaryData(value);
FoundryStateStore store = await this._binding.GetAsync(CancellationToken.None).ConfigureAwait(false);
string sessionIndexKey = BuildIndexKey(sessionId);
// The identifier is chosen once, so a retried index update does not leave behind an orphan
// checkpoint item under a discarded identifier.
CheckpointInfo checkpointInfo = new(sessionId, Guid.NewGuid().ToString("N"));
// Store the checkpoint itself, once. Only the index update below is ever retried.
await store.SetItemAsync(
BuildCheckpointKey(sessionId, checkpointInfo.CheckpointId),
new Dictionary<string, BinaryData>
{
[CheckpointField] = checkpointData,
[SessionField] = FoundryStateStoreJson.ToJsonString(sessionId),
},
cancellationToken: CancellationToken.None).ConfigureAwait(false);
// 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 < this._maxIndexUpdateAttempts; attempt++)
{
StateStoreItem? indexItem = await store.GetItemAsync(sessionIndexKey, CancellationToken.None).ConfigureAwait(false);
List<IndexEntry> entries = ReadEntries(indexItem);
if (Contains(entries, checkpointInfo.CheckpointId))
{
// Two random identifiers colliding is not realistic, but the file-system and
// in-memory stores both guard against it and this store keeps the same guarantee.
throw new InvalidOperationException(
$"The generated checkpoint identifier '{checkpointInfo.CheckpointId}' is already in use for session '{sessionId}'.");
}
entries.Add(new IndexEntry(checkpointInfo.CheckpointId, parent?.CheckpointId, HasParentMetadata: true));
try
{
await WriteEntriesAsync(store, sessionIndexKey, sessionId, entries, indexItem?.Etag).ConfigureAwait(false);
return checkpointInfo;
}
catch (FoundryStorageException ex) when (IsLostRace(ex))
{
// Another writer added a checkpoint to the same session between the read and the
// write. The checkpoint item is already stored under its own key, so the next
// attempt simply re-reads the index and appends to the newer list.
if (this._logger?.IsEnabled(LogLevel.Debug) is true)
{
this._logger.LogDebug(
ex,
"Attempt {Attempt} of {MaxAttempts} to index checkpoint '{CheckpointId}' for session '{SessionId}' lost to another writer. Retrying.",
attempt + 1,
this._maxIndexUpdateAttempts,
checkpointInfo.CheckpointId,
sessionId);
}
continue;
}
}
throw new InvalidOperationException(
$"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>
/// Returns a stored checkpoint and deletes superseded checkpoints from its lineage.
/// </summary>
/// <remarks>
/// <para>
/// The deletion keeps old checkpoints from piling up. Sibling branches and checkpoints committed
/// after the retrieved checkpoint are retained because they may belong to another persisted or
/// concurrent run.
/// </para>
/// <para>
/// A workflow writes one checkpoint per superstep and only ever resumes from the most
/// recent one, so a conversation that ran for a long time would otherwise leave behind every
/// checkpoint it ever wrote, and the index listing them would grow past the size the platform
/// accepts for a single item.
/// </para>
/// </remarks>
/// <param name="sessionId">The workflow session that owns the checkpoint.</param>
/// <param name="key">Identifies the checkpoint to return and resume.</param>
/// <returns>The stored checkpoint.</returns>
/// <exception cref="KeyNotFoundException">No such checkpoint is stored for that session.</exception>
public override async ValueTask<JsonElement> RetrieveCheckpointAsync(string sessionId, CheckpointInfo key)
{
_ = Throw.IfNullOrWhitespace(sessionId);
_ = Throw.IfNull(key);
FoundryStateStore store = await this._binding.GetAsync(CancellationToken.None).ConfigureAwait(false);
StateStoreItem? item = await store.GetItemAsync(BuildCheckpointKey(sessionId, key.CheckpointId), CancellationToken.None).ConfigureAwait(false);
if (!FoundryStateStoreJson.TryGetField(item, CheckpointField, out BinaryData? checkpointData))
{
throw new KeyNotFoundException(
$"Checkpoint '{key.CheckpointId}' was not found for session '{sessionId}' in the Foundry state store '{this.StoreName}'.");
}
JsonElement checkpoint = ParseCheckpoint(checkpointData);
// Keeps a session's checkpoints from piling up.
await this.PruneObsoleteCheckpointsAsync(store, sessionId, key.CheckpointId).ConfigureAwait(false);
return checkpoint;
}
/// <summary>
/// Reads the stored bytes into a standalone <see cref="JsonElement"/>.
/// </summary>
/// <remarks>
/// <see cref="JsonElement.ParseValue(ref Utf8JsonReader)"/> returns an element that owns its own
/// memory, so there is no document to dispose and no copy to take. The reader lives in this
/// method rather than in the caller because it is a <c>ref struct</c>, which cannot be held
/// across an <c>await</c>.
/// </remarks>
private static JsonElement ParseCheckpoint(BinaryData checkpointData)
{
Utf8JsonReader reader = new(checkpointData.ToMemory().Span);
return JsonElement.ParseValue(ref reader);
}
/// <summary>
/// Deletes superseded ancestors and legacy predecessors of the checkpoint being resumed.
/// </summary>
/// <remarks>
/// <para>
/// A session accumulates one checkpoint per superstep, and only the most recent one is ever
/// resumed from. Without this, a long conversation leaves behind every checkpoint it ever wrote
/// and the session's index item grows until it can no longer be saved.
/// </para>
/// </remarks>
private async Task PruneObsoleteCheckpointsAsync(FoundryStateStore store, string sessionId, string resumedCheckpointId)
{
string sessionIndexKey = BuildIndexKey(sessionId);
try
{
StateStoreItem? indexItem = await store.GetItemAsync(sessionIndexKey, CancellationToken.None).ConfigureAwait(false);
List<IndexEntry> entries = ReadEntries(indexItem);
int resumedIndex = entries.FindIndex(entry => entry.CheckpointId == resumedCheckpointId);
if (resumedIndex <= 0)
{
return;
}
HashSet<string> ancestorIds = GetAncestorIds(entries, resumedCheckpointId);
List<IndexEntry> obsolete = [];
List<IndexEntry> retained = [];
for (int index = 0; index < entries.Count; index++)
{
IndexEntry entry = entries[index];
// Legacy entries and parentless roots cannot be classified as branches, so they keep
// the original commit-order pruning behavior.
if (index < resumedIndex &&
(!entry.HasParentMetadata ||
entry.ParentCheckpointId is null ||
ancestorIds.Contains(entry.CheckpointId)))
{
obsolete.Add(entry);
}
else
{
retained.Add(entry);
}
}
if (obsolete.Count == 0)
{
return;
}
// The index is shortened first. A checkpoint item that is still listed but already gone
// would be read as a missing checkpoint, whereas one that is listed nowhere is simply
// never asked for.
await WriteEntriesAsync(store, sessionIndexKey, sessionId, retained, indexItem?.Etag).ConfigureAwait(false);
foreach (IndexEntry entry in obsolete)
{
try
{
await store.DeleteItemAsync(BuildCheckpointKey(sessionId, entry.CheckpointId), cancellationToken: CancellationToken.None).ConfigureAwait(false);
}
catch (FoundryStorageNotFoundException ex)
{
// Already deleted, by an earlier attempt or another instance.
if (this._logger?.IsEnabled(LogLevel.Debug) is true)
{
this._logger.LogDebug(
ex,
"Obsolete checkpoint '{CheckpointId}' of session '{SessionId}' was already gone.",
entry.CheckpointId,
sessionId);
}
}
}
}
catch (FoundryStorageException ex) when (IsLostRace(ex))
{
// Another instance updated the same session index first. Leaving the old items in place
// is safer than deleting against a stale index; a later resume can prune them.
if (this._logger?.IsEnabled(LogLevel.Debug) is true)
{
this._logger.LogDebug(
ex,
"Pruning obsolete checkpoints of session '{SessionId}' lost to another writer. The old checkpoints remain until a later resume or expiry.",
sessionId);
}
}
catch (FoundryStorageException ex)
{
// Not a lost race: the store refused the call for a reason of its own, a credential or a
// network problem for instance. The checkpoint has already been retrieved by this point,
// so failing the resume over housekeeping would break a conversation that was about to
// carry on. It is reported instead, because left unreported this is how a session's
// checkpoints would silently pile up until the index no longer fits.
this._logger?.LogWarning(
ex,
"Could not prune obsolete checkpoints of session '{SessionId}' in the Foundry state store '{StoreName}'. The resume itself succeeded; the leftovers stay until the store's own expiry removes them.",
sessionId,
this.StoreName);
}
}
private static HashSet<string> GetAncestorIds(List<IndexEntry> entries, string checkpointId)
{
Dictionary<string, IndexEntry> entriesById = [];
foreach (IndexEntry entry in entries)
{
entriesById[entry.CheckpointId] = entry;
}
HashSet<string> ancestors = new(StringComparer.Ordinal);
if (!entriesById.TryGetValue(checkpointId, out IndexEntry? current) ||
!current.HasParentMetadata)
{
return ancestors;
}
string? parentId = current.ParentCheckpointId;
while (parentId is not null && ancestors.Add(parentId))
{
if (!entriesById.TryGetValue(parentId, out IndexEntry? parent) ||
!parent.HasParentMetadata)
{
break;
}
parentId = parent.ParentCheckpointId;
}
return ancestors;
}
/// <inheritdoc/>
public override async ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null)
{
_ = Throw.IfNullOrWhitespace(sessionId);
FoundryStateStore store = await this._binding.GetAsync(CancellationToken.None).ConfigureAwait(false);
StateStoreItem? indexItem = await store.GetItemAsync(BuildIndexKey(sessionId), CancellationToken.None).ConfigureAwait(false);
List<CheckpointInfo> result = [];
foreach (IndexEntry entry in ReadEntries(indexItem))
{
// Same filter the file-system store applies: an entry written before parents were
// recorded is always included, because its parent is unknown rather than different.
if (withParent is null || !entry.HasParentMetadata || entry.ParentCheckpointId == withParent.CheckpointId)
{
result.Add(new CheckpointInfo(sessionId, entry.CheckpointId));
}
}
return result;
}
/// <summary>Reports whether the index already lists the given checkpoint identifier.</summary>
private static bool Contains(List<IndexEntry> entries, string checkpointId)
{
foreach (IndexEntry entry in entries)
{
if (entry.CheckpointId == checkpointId)
{
return true;
}
}
return false;
}
/// <summary>
/// Reports whether a storage failure means "someone else wrote this item first", which is the
/// only failure this store retries. A 412 says the optimistic concurrency check failed; a 409
/// says an item this store expected to be absent had already been created.
/// </summary>
private static bool IsLostRace(FoundryStorageException exception)
=> exception is FoundryStoragePreconditionException or FoundryStorageConflictException;
private static List<IndexEntry> ReadEntries(StateStoreItem? indexItem)
{
List<IndexEntry> entries = [];
if (!FoundryStateStoreJson.TryGetField(indexItem, EntriesField, out BinaryData? entriesData))
{
return entries;
}
using JsonDocument document = JsonDocument.Parse(entriesData.ToMemory());
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
return entries;
}
foreach (JsonElement element in document.RootElement.EnumerateArray())
{
if (element.ValueKind != JsonValueKind.Object ||
!element.TryGetProperty(EntryIdProperty, out JsonElement idElement) ||
idElement.GetString() is not string checkpointId ||
checkpointId.Length == 0)
{
continue;
}
string? parentId = element.TryGetProperty(EntryParentProperty, out JsonElement parentElement) && parentElement.ValueKind == JsonValueKind.String
? parentElement.GetString()
: null;
bool hasParentMetadata = element.TryGetProperty(EntryHasParentProperty, out JsonElement hasParentElement) &&
hasParentElement.ValueKind == JsonValueKind.True;
entries.Add(new IndexEntry(checkpointId, parentId, hasParentMetadata));
}
return entries;
}
private static async Task WriteEntriesAsync(FoundryStateStore store, string sessionIndexKey, string sessionId, List<IndexEntry> entries, string? ifMatch)
{
Dictionary<string, BinaryData> value = new()
{
[EntriesField] = WriteEntries(entries),
[SessionField] = FoundryStateStoreJson.ToJsonString(sessionId),
};
if (ifMatch is null)
{
// The index did not exist a moment ago. CreateItemAsync fails with a conflict if another
// writer created it in the meantime, which the caller retries.
await store.CreateItemAsync(sessionIndexKey, value, cancellationToken: CancellationToken.None).ConfigureAwait(false);
return;
}
await store.SetItemAsync(sessionIndexKey, value, ifMatch: ifMatch, cancellationToken: CancellationToken.None).ConfigureAwait(false);
}
private static BinaryData WriteEntries(List<IndexEntry> entries)
{
System.Buffers.ArrayBufferWriter<byte> buffer = new();
using (Utf8JsonWriter writer = new(buffer))
{
writer.WriteStartArray();
foreach (IndexEntry entry in entries)
{
writer.WriteStartObject();
writer.WriteString(EntryIdProperty, entry.CheckpointId);
if (entry.ParentCheckpointId is not null)
{
writer.WriteString(EntryParentProperty, entry.ParentCheckpointId);
}
writer.WriteBoolean(EntryHasParentProperty, entry.HasParentMetadata);
writer.WriteEndObject();
}
writer.WriteEndArray();
}
return BinaryData.FromBytes(buffer.WrittenMemory);
}
/// <summary>
/// Builds the key of the item holding a session's ordered checkpoint index. The <c>wi-</c>
/// prefix separates index items from checkpoint items, which share one state store.
/// </summary>
internal static string BuildIndexKey(string sessionId) => $"wi-{HashKeyParts(sessionId)}";
/// <summary>
/// Builds the key of the item holding one checkpoint's serialized state. The <c>wc-</c> prefix
/// separates checkpoint items from index items, which share one state store.
/// </summary>
internal static string BuildCheckpointKey(string sessionId, string checkpointId) => $"wc-{HashKeyParts(sessionId, checkpointId)}";
/// <summary>
/// Folds the identifiers an item key is made of into a fixed-length string.
/// </summary>
/// <remarks>
/// The platform caps an item key at 128 characters, and neither a workflow session identifier
/// nor a checkpoint identifier has a bounded length, so they are hashed. Hashing rather than
/// truncating matters: two sessions whose identifiers share a long prefix would otherwise be cut
/// down to the same key and overwrite each other's checkpoints. The parts are joined with a NUL
/// character, which cannot appear inside either identifier, so no two different combinations can
/// produce the same input. The result is written in the URL-safe base64 alphabet because the
/// key becomes a segment of the request path the platform client builds.
/// </remarks>
/// <param name="parts">The identifiers that make this key unique, in a fixed order.</param>
/// <returns>The hashed key body, without the prefix that says what kind of item it is.</returns>
private static string HashKeyParts(params string[] parts)
{
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("\u0000", parts)));
return Convert.ToBase64String(hash).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
private sealed record IndexEntry(string CheckpointId, string? ParentCheckpointId, bool HasParentMetadata);
}
@@ -50,50 +50,4 @@ 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; }
}
@@ -1,126 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Core.Storage;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Resolves a <see cref="FoundryStateStore"/> once and hands the same instance to every later
/// caller. Resolving costs a network round trip, plus one more the very first time to create the
/// store on the platform, so it deliberately does not happen per request.
/// </summary>
/// <remarks>
/// A failed attempt is not kept: the next call starts a fresh one, so a transient network or
/// permission failure at startup does not leave the store unusable for the life of the process.
/// </remarks>
internal sealed class FoundryStateStoreBinding
{
private readonly Func<CancellationToken, Task<FoundryStateStore>> _factory;
private readonly object _gate = new();
private Task<FoundryStateStore>? _pending;
public FoundryStateStoreBinding(Func<CancellationToken, Task<FoundryStateStore>> factory)
{
this._factory = Throw.IfNull(factory);
}
public async ValueTask<FoundryStateStore> GetAsync(CancellationToken cancellationToken)
{
Task<FoundryStateStore> binding;
lock (this._gate)
{
// The shared work is started without the caller's cancellation token so one cancelled
// request cannot cancel the binding for every other request.
binding = this._pending ??= this._factory(CancellationToken.None);
}
try
{
// WaitAsync applies the caller's token to this caller's wait only.
return await binding.WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested && !binding.IsCompleted)
{
// This caller stopped waiting, but the shared binding is still usable by everyone else.
throw;
}
catch
{
lock (this._gate)
{
if (ReferenceEquals(this._pending, binding))
{
this._pending = null;
}
}
throw;
}
}
}
/// <summary>
/// Small JSON helpers shared by the Foundry-backed stores. They are written with
/// <see cref="Utf8JsonWriter"/> rather than a serializer so the callers stay trimming and
/// ahead-of-time compilation safe.
/// </summary>
internal static class FoundryStateStoreJson
{
/// <summary>Writes a <see cref="JsonElement"/> out as UTF-8 bytes.</summary>
public static BinaryData ToBinaryData(JsonElement element)
{
ArrayBufferWriter<byte> buffer = new();
using (Utf8JsonWriter writer = new(buffer))
{
element.WriteTo(writer);
}
return BinaryData.FromBytes(buffer.WrittenMemory);
}
/// <summary>Encodes a plain string as a JSON string value.</summary>
public static BinaryData ToJsonString(string value)
{
ArrayBufferWriter<byte> buffer = new();
using (Utf8JsonWriter writer = new(buffer))
{
writer.WriteStringValue(value);
}
return BinaryData.FromBytes(buffer.WrittenMemory);
}
/// <summary>
/// Reads one field out of a state-store item body, treating a missing item, a missing field and
/// an empty value all as "nothing stored".
/// </summary>
public static bool TryGetField(StateStoreItem? item, string field, [NotNullWhen(true)] out BinaryData? data)
{
data = null;
if (item is null)
{
return false;
}
if (!item.Value.TryGetValue(field, out BinaryData? value) || value is null)
{
return false;
}
if (value.ToMemory().IsEmpty)
{
return false;
}
data = value;
return true;
}
}
@@ -600,10 +600,9 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
}
};
// McpClient.CreateAsync performs discovery-first negotiation with down-level fallback and
// can throw for an unreachable proxy (the deferred-toolbox case, retried per request).
// Keep it inside the try so the HttpClient is always disposed on failure rather than
// leaking a socket on every retry.
// McpClient.CreateAsync runs the MCP initialize handshake and can throw for an unreachable
// proxy (the deferred-toolbox case, retried per request). Keep it inside the try so the
// HttpClient is always disposed on failure rather than leaking a socket on every retry.
McpClient? client = null;
IList<McpClientTool> mcpTools;
try
@@ -43,7 +43,7 @@ internal static class HostedProtocolCompatibility
internal const string UnsupportedProtocolErrorCode = "unsupported_container_protocol_version";
/// <summary>
/// Returns the error response when this <c>2.0.0</c>-only image is served container protocol
/// Returns the error to throw when this <c>2.0.0</c>-only image is served container protocol
/// <c>1.0.0</c>, or <see langword="null"/> when the request is compatible (or the container is not
/// hosted by Foundry, e.g. local development).
/// </summary>
@@ -1,80 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Text;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Rejects an unsupported hosted Responses protocol before the request enters AgentServer's
/// resilient task boundary.
/// </summary>
internal sealed class HostedProtocolCompatibilityFilter : IEndpointFilter
{
private const string CallIdHeaderName = "x-agent-foundry-call-id";
private const string ErrorSourceHeaderName = "x-platform-error-source";
private const string UpstreamErrorSource = "upstream";
private readonly bool _isHosted;
private readonly ILogger<HostedProtocolCompatibilityFilter> _logger;
internal HostedProtocolCompatibilityFilter(
IConfiguration configuration,
ILogger<HostedProtocolCompatibilityFilter> logger)
{
_ = Throw.IfNull(configuration);
this._logger = Throw.IfNull(logger);
this._isHosted = !string.IsNullOrEmpty(
configuration[FoundryHostingExtensions.FoundryHostingEnvironmentKey]);
}
/// <inheritdoc/>
public ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
_ = Throw.IfNull(context);
_ = Throw.IfNull(next);
HttpRequest request = context.HttpContext.Request;
if (!IsCreateResponseRequest(request))
{
return next(context);
}
string? callId = request.Headers.TryGetValue(CallIdHeaderName, out var values)
? values.ToString()
: null;
var unsupportedProtocolError =
HostedProtocolCompatibility.GetUnsupportedProtocolError(this._isHosted, callId);
if (unsupportedProtocolError is null)
{
return next(context);
}
this._logger.LogError(
"Hosted container served unsupported Responses protocol 1.0.0 (no x-agent-foundry-call-id header); this image requires protocol 2.0.0.");
BinaryData responseData =
((IPersistableModel<ApiErrorResponse>)new ApiErrorResponse(unsupportedProtocolError.Error))
.Write(ModelReaderWriterOptions.Json);
context.HttpContext.Response.Headers[ErrorSourceHeaderName] = UpstreamErrorSource;
return ValueTask.FromResult<object?>(
Results.Text(
responseData.ToString(),
contentType: "application/json",
contentEncoding: Encoding.UTF8,
statusCode: unsupportedProtocolError.StatusCode));
}
private static bool IsCreateResponseRequest(HttpRequest request)
=> HttpMethods.IsPost(request.Method) &&
request.Path.Value?.EndsWith("/responses", StringComparison.OrdinalIgnoreCase) is true;
}
@@ -6,11 +6,11 @@ using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Foundry.Hosting;
@@ -46,7 +46,7 @@ internal sealed class HostedStoredOutputHealthCheck : IHealthCheck
IOptions<FoundryResponsesOptions>? hostingOptions = null,
ILogger<HostedStoredOutputHealthCheck>? logger = null)
{
_ = Throw.IfNull(serviceProvider);
ArgumentNullException.ThrowIfNull(serviceProvider);
this._serviceProvider = serviceProvider;
this._hostingOptions = hostingOptions?.Value ?? new FoundryResponsesOptions();
@@ -55,7 +55,7 @@ internal sealed class HostedStoredOutputHealthCheck : IHealthCheck
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(context);
ArgumentNullException.ThrowIfNull(context);
if (this._hostingOptions.AllowStoredOutputEnabled)
{
@@ -68,7 +68,7 @@ internal sealed class HostedStoredOutputHealthCheck : IHealthCheck
foreach (var agent in this.ResolveAgents())
{
if (agent.GetService<ChatClientAgent>() is not { } chatClientAgent)
if (agent.GetService<ChatClientAgent>() is null)
{
// Hosting only reaches the store setting through ChatClientAgent's chat options, so any
// other agent runs untouched and there is nothing to report.
@@ -97,39 +97,30 @@ internal sealed class HostedStoredOutputHealthCheck : IHealthCheck
}
/// <summary>
/// Runs a stand-in built from the agent's own configuration, with its chat client replaced by one
/// that calls nothing, and reports whether the request that configuration produces asks for the
/// response to be stored.
/// Runs the agent with its chat client replaced by one that calls nothing, and reports whether the
/// request the agent built asks for the response to be stored.
/// </summary>
/// <remarks>
/// The run carries no chat options of its own, so the agent's own configuration is what reaches the
/// probe. Overriding the setting here, the way the request handler does per turn, would only show
/// the override back.
/// <para>
/// A stand-in is built rather than running the registered agent because that agent's chat history
/// provider and context providers would run with it. Those are the parts most likely to reach
/// outside the container, a memory or search provider for instance, and to write state: a readiness
/// probe would then make external calls and add its own empty turn to real conversations, on every
/// probe, for a run that asks the agent nothing. The stand-in keeps everything that decides the
/// stored output setting, the chat options and the raw request factory among them, and drops both
/// kinds of provider, so the probe stays free of side effects.
/// The agent's chat history provider is stood down for this run, because it would otherwise read
/// and write its own store on every readiness probe. A provider backed by a database would then be
/// doing external calls, and adding this probe's empty turn to a real conversation, for a run that
/// asks the agent nothing.
/// </para>
/// Wrappers are not run by readiness because their middleware may have side effects. Middleware
/// that changes the effective run options therefore remains unknown and does not fail readiness.
/// The request handler performs the authoritative post-run check and rejects any turn that
/// unexpectedly produced a downstream conversation id.
/// </remarks>
private async Task<bool> StoresItsOwnResponsesAsync(AIAgent agent, CancellationToken cancellationToken)
{
var probe = new StoredOutputProbeChatClient();
var probeOptions = agent.GetService<ChatClientAgentOptions>()?.Clone() ?? new ChatClientAgentOptions();
probeOptions.ChatHistoryProvider = null;
probeOptions.AIContextProviders = null;
var runOptions = new ChatClientAgentRunOptions { ChatClientFactory = _ => probe };
runOptions.AdditionalProperties ??= [];
runOptions.AdditionalProperties.Add<ChatHistoryProvider>(new VolatileChatHistoryProvider());
try
{
var probeAgent = new ChatClientAgent(probe, probeOptions);
await probeAgent.RunAsync([], cancellationToken: cancellationToken).ConfigureAwait(false);
await agent.RunAsync([], options: runOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
@@ -158,5 +149,15 @@ internal sealed class HostedStoredOutputHealthCheck : IHealthCheck
/// <summary>
/// Every agent this container can serve: the ones registered under a name, plus the default.
/// </summary>
private List<AIAgent> ResolveAgents() => FoundryHostingExtensions.ResolveRegisteredAgents(this._serviceProvider);
private List<AIAgent> ResolveAgents()
{
var agents = new List<AIAgent>(this._serviceProvider.GetKeyedServices<AIAgent>(KeyedService.AnyKey));
if (this._serviceProvider.GetService<AIAgent>() is { } defaultAgent && !agents.Contains(defaultAgent))
{
agents.Add(defaultAgent);
}
return agents;
}
}
@@ -1,101 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Reports, on the <c>GET /readiness</c> probe, a registered workflow agent that was built with a
/// checkpoint manager of its own, so a container whose workflow state would be written somewhere
/// hosting cannot manage is caught before it takes any traffic.
/// </summary>
/// <remarks>
/// <para>
/// A hosted workflow has its checkpoints redirected to the Foundry durable state store, so that the
/// state a conversation builds up survives the container being restarted or replaced and is readable
/// by every instance of the agent. An agent that already names its own checkpoint manager is left
/// alone, because overriding an explicit choice silently would be worse. The result is a container
/// whose workflow state goes somewhere hosting does not manage, which is reported here rather than
/// discovered later.
/// </para>
/// <para>
/// Only an agent that runs a workflow is considered. Everything else, a <see cref="ChatClientAgent"/>
/// or an agent written by the container author, has no checkpoints and is passed over.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class HostedWorkflowCheckpointingHealthCheck : IHealthCheck
{
private readonly IServiceProvider _serviceProvider;
public HostedWorkflowCheckpointingHealthCheck(IServiceProvider serviceProvider)
{
_ = Throw.IfNull(serviceProvider);
this._serviceProvider = serviceProvider;
}
/// <summary>
/// Whether the process is running inside a Foundry container. Settable so a test does not depend
/// on the process-wide, statically-cached <see cref="FoundryEnvironment.IsHosted"/> value.
/// </summary>
internal bool IsHosted { get; set; } = FoundryEnvironment.IsHosted;
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(context);
if (!this.IsHosted)
{
// Nothing redirects checkpoints outside a Foundry container, so an agent that brings its
// own checkpoint manager is not competing with anything.
return Task.FromResult(HealthCheckResult.Healthy(
"Workflow checkpointing: not running in a Foundry container, so workflow checkpoints are left where each agent puts them."));
}
List<string> incompatibleAgents = [];
var checkedAgents = 0;
foreach (var agent in FoundryHostingExtensions.ResolveRegisteredAgents(this._serviceProvider))
{
if (agent.GetService<WorkflowAgentMetadata>() is not { } metadata)
{
continue;
}
checkedAgents++;
AIAgent redirected = FoundryHostingExtensions.ApplyWorkflowCheckpointing(
agent,
this._serviceProvider.GetService<ILoggerFactory>());
if (metadata.UsesOwnCheckpointStorage || ReferenceEquals(redirected, agent))
{
incompatibleAgents.Add(agent.Name ?? agent.Id);
}
}
if (incompatibleAgents.Count > 0)
{
return Task.FromResult(new HealthCheckResult(
status: context.Registration.FailureStatus,
description: string.Create(
CultureInfo.InvariantCulture,
$"Workflow checkpointing: {incompatibleAgents.Count} registered workflow agent(s) cannot use the checkpoint store supplied by hosting. Remove a caller-configured checkpoint manager and register the workflow agent directly rather than behind middleware."),
data: new Dictionary<string, object>(StringComparer.Ordinal) { ["incompatibleAgents"] = incompatibleAgents }));
}
return Task.FromResult(HealthCheckResult.Healthy(
string.Create(CultureInfo.InvariantCulture, $"Workflow checkpointing: {checkedAgents} workflow agent(s) checked, all leaving their checkpoint storage to hosting.")));
}
}
@@ -31,10 +31,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.Core" />
<PackageReference Include="Azure.AI.AgentServer.Responses" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
</ItemGroup>
@@ -32,9 +32,6 @@ 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.")]
@@ -43,7 +40,6 @@ 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;
@@ -82,21 +78,6 @@ 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;
}
@@ -2,17 +2,13 @@
using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using System.Runtime.CompilerServices;
using Azure.AI.AgentServer.Responses;
using Azure.Core;
using Azure.Identity;
using Microsoft.Agents.AI.Workflows;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.AI;
@@ -23,7 +19,6 @@ using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Foundry.Hosting;
@@ -58,28 +53,18 @@ 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, or to opt in to durable long-running
/// (resilient) background responses via <see cref="FoundryResponsesOptions.ResilientBackground"/>.
/// agent's own service to store the responses it produces.
/// </param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFoundryResponses(this IServiceCollection services, Action<FoundryResponsesOptions>? configure = null)
{
_ = Throw.IfNull(services);
FoundryResponsesOptions configuredOptions = CreateFoundryResponsesOptions(configure);
bool serverAdded = AddResponsesServerOnce(
services,
configuredOptions,
configure is not null);
ArgumentNullException.ThrowIfNull(services);
services.AddResponsesServer();
services.AddHealthChecks();
ConfigureFoundryListenPort(services);
ConfigureFoundryResponsesOptions(
services,
configuredOptions,
includeServerOptions: serverAdded,
applyOptions: serverAdded || configure is not null);
services.TryAddSingleton<AgentSessionStore>(_ => CreateDefaultAgentSessionStore());
RegisterResponseHandler(services);
MarkFeatureUsed();
ConfigureFoundryResponsesOptions(services, configure);
services.TryAddSingleton<AgentSessionStore>(_ => FileSystemAgentSessionStore.CreateDefault());
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
return services;
}
@@ -105,11 +90,10 @@ public static class FoundryHostingExtensions
/// </remarks>
/// <param name="services">The service collection.</param>
/// <param name="agent">The agent instance to register.</param>
/// <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="agentSessionStore">The agent session store to use for managing agent sessions server-side. If null, a file-system session store is used, rooted at <c>/.checkpoints</c> when running in a Foundry hosted environment and <c>{cwd}/.checkpoints</c> locally.</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, or to opt in to durable long-running
/// (resilient) background responses via <see cref="FoundryResponsesOptions.ResilientBackground"/>.
/// agent's own service to store the responses it produces.
/// </param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFoundryResponses(
@@ -118,22 +102,14 @@ public static class FoundryHostingExtensions
AgentSessionStore? agentSessionStore = null,
Action<FoundryResponsesOptions>? configure = null)
{
_ = Throw.IfNull(services);
_ = Throw.IfNull(agent);
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(agent);
FoundryResponsesOptions configuredOptions = CreateFoundryResponsesOptions(configure);
bool serverAdded = AddResponsesServerOnce(
services,
configuredOptions,
configure is not null);
services.AddResponsesServer();
services.AddHealthChecks();
ConfigureFoundryListenPort(services);
ConfigureFoundryResponsesOptions(
services,
configuredOptions,
includeServerOptions: serverAdded,
applyOptions: serverAdded || configure is not null);
agentSessionStore ??= CreateDefaultAgentSessionStore();
ConfigureFoundryResponsesOptions(services, configure);
agentSessionStore ??= FileSystemAgentSessionStore.CreateDefault();
if (!string.IsNullOrWhiteSpace(agent.Name))
{
@@ -146,86 +122,44 @@ public static class FoundryHostingExtensions
services.TryAddSingleton(agent);
services.TryAddSingleton(agentSessionStore);
RegisterResponseHandler(services);
MarkFeatureUsed();
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
return services;
}
/// <summary>
/// Applies the caller's <see cref="FoundryResponsesOptions"/> and registers the readiness checks
/// that report a misconfigured agent: one having its own service store the responses it produces,
/// and a workflow agent writing its checkpoints somewhere hosting does not manage.
/// Applies the caller's <see cref="FoundryResponsesOptions"/> and registers the readiness check that
/// reports an agent configured to have its own service store the responses it produces.
/// </summary>
/// <remarks>
/// The checks are registered on the same <c>/readiness</c> pipeline that <see cref="MapFoundryResponses"/>
/// maps, so such a container never takes traffic.
/// The check is registered on the same <c>/readiness</c> pipeline that <see cref="MapFoundryResponses"/>
/// maps, so a container that would record the conversation twice 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 FoundryResponsesOptions CreateFoundryResponsesOptions(Action<FoundryResponsesOptions>? configure)
private static void ConfigureFoundryResponsesOptions(IServiceCollection services, Action<FoundryResponsesOptions>? configure)
{
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)
if (configure is not null)
{
services.Configure<FoundryResponsesOptions>(options =>
{
options.AllowStoredOutputEnabled = configuredOptions.AllowStoredOutputEnabled;
options.IncludeReasoningEncryptedContent = configuredOptions.IncludeReasoningEncryptedContent;
if (includeServerOptions)
{
options.ResilientBackground = configuredOptions.ResilientBackground;
options.SteerableConversations = configuredOptions.SteerableConversations;
}
});
services.Configure(configure);
}
AddReadinessCheckOnce(services, "foundry-stored-output", sp => ActivatorUtilities.CreateInstance<HostedStoredOutputHealthCheck>(sp));
AddReadinessCheckOnce(services, "foundry-workflow-checkpointing", sp => ActivatorUtilities.CreateInstance<HostedWorkflowCheckpointingHealthCheck>(sp));
}
/// <summary>
/// Registers a readiness check under a name, skipping the registration when that name is already
/// taken, because <c>AddCheck</c> does not dedupe and both <c>AddFoundryResponses</c> overloads
/// are documented as safe to call more than once.
/// </summary>
private static void AddReadinessCheckOnce(IServiceCollection services, string name, Func<IServiceProvider, IHealthCheck> factory) =>
const string HealthCheckName = "foundry-stored-output";
services.Configure<HealthCheckServiceOptions>(opts =>
{
foreach (var existing in opts.Registrations)
{
if (string.Equals(existing.Name, name, StringComparison.Ordinal))
if (string.Equals(existing.Name, HealthCheckName, StringComparison.Ordinal))
{
return;
}
}
opts.Registrations.Add(new HealthCheckRegistration(
name: name,
factory: factory,
name: HealthCheckName,
factory: sp => ActivatorUtilities.CreateInstance<HostedStoredOutputHealthCheck>(sp),
failureStatus: HealthStatus.Unhealthy,
tags: ["foundry", "responses", "readiness"]));
});
}
/// <summary>
/// Registers the Foundry Toolbox service, which eagerly connects to the Foundry Toolboxes
@@ -272,8 +206,8 @@ public static class FoundryHostingExtensions
Action<FoundryToolboxOptions>? configureOptions,
params string[] toolboxNames)
{
_ = Throw.IfNull(services);
_ = Throw.IfNull(credential);
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(credential);
if (services.Any(d => d.ServiceType == typeof(FoundryToolboxService)))
{
@@ -363,24 +297,12 @@ public static class FoundryHostingExtensions
/// <returns>The endpoint route builder for chaining.</returns>
public static IEndpointRouteBuilder MapFoundryResponses(this IEndpointRouteBuilder endpoints, string prefix = "")
{
_ = Throw.IfNull(endpoints);
RouteGroupBuilder responsesEndpoints = endpoints.MapGroup(string.Empty);
responsesEndpoints.AddEndpointFilter(new HostedProtocolCompatibilityFilter(
endpoints.ServiceProvider.GetRequiredService<IConfiguration>(),
endpoints.ServiceProvider.GetRequiredService<ILogger<HostedProtocolCompatibilityFilter>>()));
responsesEndpoints.MapResponsesServer(prefix);
ArgumentNullException.ThrowIfNull(endpoints);
endpoints.MapResponsesServer(prefix);
MapReadinessIfMissing(endpoints);
MarkFeatureUsed();
return endpoints;
}
private static void MarkFeatureUsed()
{
#pragma warning disable MAAI001
FeatureUsage.MarkUsed((int)FeatureIndex.FoundryHosting);
#pragma warning restore MAAI001
}
/// <summary>
/// Configuration key the Foundry hosting platform populates with a non-empty value inside a
/// hosted container. It is the documented way for container code to detect a Foundry context.
@@ -398,96 +320,12 @@ public static class FoundryHostingExtensions
/// </summary>
internal const int DefaultListenPort = 8088;
/// <summary>
/// Registers the Responses Server SDK exactly once per service collection.
/// </summary>
/// <remarks>
/// <c>AddResponsesServer</c> registers a resilient task under a fixed name and throws when that
/// name is already taken, so calling it a second time on the same service collection fails.
/// Both <c>AddFoundryResponses</c> overloads are documented as safe to call more than once, and
/// a host that registers several agents naturally does, so the second and later calls are
/// skipped here.
/// </remarks>
private static bool AddResponsesServerOnce(
IServiceCollection services,
FoundryResponsesOptions configuredOptions,
bool hasConfigureCallback)
{
FoundryResponsesServerMarker? marker = services
.LastOrDefault(static descriptor =>
descriptor.ServiceType == typeof(FoundryResponsesServerMarker))
?.ImplementationInstance as FoundryResponsesServerMarker;
if (marker is not null)
{
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(new FoundryResponsesServerMarker(
configuredOptions.ResilientBackground,
configuredOptions.SteerableConversations));
services.AddResponsesServer(options =>
{
options.ResilientBackground = configuredOptions.ResilientBackground;
options.SteerableConversations = configuredOptions.SteerableConversations;
});
return true;
}
/// <summary>
/// Creates the <see cref="AgentSessionStore"/> used when the caller did not supply one.
/// </summary>
/// <remarks>
/// The AgentServer SDK selects the backend. Inside a Foundry container it uses the platform's
/// durable state store, which survives replacement and is readable by every instance. Anywhere
/// else it uses the SDK's local state-store fallback under <c>~/.agentserver/state_stores</c>.
/// </remarks>
private static FoundryAgentSessionStore CreateDefaultAgentSessionStore() =>
new(credential: CreateStateStoreCredential());
/// <summary>
/// Every agent a container can serve: the ones registered under a name, plus the default.
/// </summary>
/// <param name="serviceProvider">The provider the agents were registered with.</param>
/// <returns>The registered agents, without duplicates.</returns>
internal static List<AIAgent> ResolveRegisteredAgents(IServiceProvider serviceProvider)
{
var agents = new List<AIAgent>(serviceProvider.GetKeyedServices<AIAgent>(KeyedService.AnyKey));
if (serviceProvider.GetService<AIAgent>() is { } defaultAgent && !agents.Contains(defaultAgent))
{
agents.Add(defaultAgent);
}
return agents;
}
/// <summary>
/// Marker registered once per <see cref="IServiceCollection"/> so the Foundry listen-port
/// configuration is applied at most once, even across multiple <c>AddFoundryResponses</c> calls.
/// </summary>
private sealed class FoundryListenPortMarker;
/// <summary>
/// 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(
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
/// <c>WebApplication.CreateBuilder</c> host (Tier 3) works with no Dockerfile. Mirrors
@@ -613,72 +451,6 @@ public static class FoundryHostingExtensions
.Build();
}
/// <summary>
/// Points a workflow-hosting agent at the Foundry durable state store for its checkpoints,
/// when running inside a Foundry container.
/// </summary>
/// <remarks>
/// <para>
/// This runs when the agent is resolved for a request rather than when it is registered,
/// because a host can register agents as factories that are only built later, and because a
/// registered agent is a finished object whose checkpoint storage is fixed at construction.
/// </para>
/// <para>
/// Without this, a hosted workflow keeps every checkpoint of a session inside the saved session
/// record, and the platform limits a single record to 1 MB, so a long workflow eventually stops
/// being able to save. With it, each checkpoint becomes its own record and the session keeps
/// only the pointer to the last one.
/// </para>
/// <para>
/// The method is a no-op when the agent does not host a workflow or when the workflow was built
/// with an explicit checkpoint manager. The AgentServer SDK selects the hosted or local
/// state-store backend. The redirected agent is cached against the agent it came from, so the
/// substitution happens once rather than on every request.
/// </para>
/// </remarks>
/// <param name="agent">The resolved agent.</param>
/// <param name="loggerFactory">Creates the logger the checkpoint store reports through.</param>
/// <returns>The agent to serve the request with.</returns>
internal static AIAgent ApplyWorkflowCheckpointing(AIAgent agent, ILoggerFactory? loggerFactory = null)
{
return s_workflowCheckpointingAgents.GetValue(
agent,
source => source.WithCheckpointing(GetFoundryWorkflowCheckpointManager(loggerFactory)));
}
/// <summary>
/// The single checkpoint manager shared by every hosted workflow in this process. It is created
/// on first use so that no credential is built and no platform call is made when the process is
/// not running on the platform, which also means the first caller supplies its logger.
/// </summary>
private static CheckpointManager GetFoundryWorkflowCheckpointManager(ILoggerFactory? loggerFactory)
{
lock (s_checkpointManagerGate)
{
return s_foundryWorkflowCheckpointManager ??= CheckpointManager.CreateJson(
new FoundryJsonCheckpointStore(
credential: CreateStateStoreCredential(),
loggerFactory: loggerFactory));
}
}
/// <summary>
/// Creates the credential required by the hosted state-store backend. The beta.29 SDK requires
/// no credential for its local fallback, so local development does not construct one.
/// </summary>
private static DefaultAzureCredential? CreateStateStoreCredential() =>
FoundryEnvironment.IsHosted ? new DefaultAzureCredential() : null;
private static readonly object s_checkpointManagerGate = new();
private static CheckpointManager? s_foundryWorkflowCheckpointManager;
/// <summary>
/// Caches the redirected copy of each agent. Rebuilding it per request would restart the
/// agent's protocol validation and throw away the session identifiers it tracks, so the copy
/// has to live as long as the agent it was made from.
/// </summary>
private static readonly ConditionalWeakTable<AIAgent, AIAgent> s_workflowCheckpointingAgents = new();
/// <summary>
/// Registers the hosted-agent <c>User-Agent</c> supplement policy
/// (<see cref="HostedAgentUserAgentPolicy"/>) on the agent's underlying chat client via the
@@ -1,16 +1,88 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Internal;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Reflection;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Foundry;
internal static class FoundryUserAgentPolicies
/// <summary>
/// Framework-wide pipeline policy that appends the <c>agent-framework-dotnet/{version}</c>
/// segment to outgoing <c>User-Agent</c> headers, mirroring the
/// <c>agent-framework-python/{version}</c> contract used by every Python provider package.
/// </summary>
/// <remarks>
/// <para>
/// The segment value is computed once from the <c>Microsoft.Agents.AI.Foundry</c> assembly's
/// <see cref="AssemblyInformationalVersionAttribute"/>. The policy is idempotent on retries: if
/// the segment is already present in the <c>User-Agent</c> header, the policy does not append
/// it again.
/// </para>
/// <para>
/// The policy is registered by <c>FoundryChatClient</c> on the underlying chat client's
/// <c>OpenAIRequestPolicies</c> hook so every outbound Foundry call carries the segment. The
/// policy is currently colocated with the Foundry package; it is expected to migrate to a
/// framework-wide location (such as <c>Microsoft.Agents.AI</c>) once another provider package
/// adopts the same User-Agent contract.
/// </para>
/// </remarks>
internal sealed class AgentFrameworkUserAgentPolicy : PipelinePolicy
{
internal static AgentFrameworkUserAgentPolicyRegistration Registration { get; } =
new(
[
"services.ai.azure.com",
"inference.ai.azure.com",
],
BaseUserAgentScope.AllRequests);
/// <summary>Gets the singleton policy instance.</summary>
public static AgentFrameworkUserAgentPolicy Instance { get; } = new AgentFrameworkUserAgentPolicy();
private static readonly string s_segmentValue = CreateSegmentValue();
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
AppendHeader(message);
ProcessNext(message, pipeline, currentIndex);
}
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
AppendHeader(message);
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
}
private static void AppendHeader(PipelineMessage message)
{
if (message.Request.Headers.TryGetValue("User-Agent", out var existing) && !string.IsNullOrEmpty(existing))
{
// Guard against double-append on retries or when the policy
// is registered on multiple pipeline positions.
if (existing!.Contains(s_segmentValue))
{
return;
}
message.Request.Headers.Set("User-Agent", $"{existing} {s_segmentValue}");
}
else
{
message.Request.Headers.Set("User-Agent", s_segmentValue);
}
}
private static string CreateSegmentValue()
{
const string Name = "agent-framework-dotnet";
if (typeof(AgentFrameworkUserAgentPolicy).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion is string version)
{
int pos = version.IndexOf('+');
if (pos >= 0)
{
version = version.Substring(0, pos);
}
if (version.Length > 0)
{
return $"{Name}/{version}";
}
}
return Name;
}
}
@@ -41,8 +41,6 @@ internal sealed class ClientHeadersAgent : DelegatingAIAgent
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryAgent);
var snapshot = TrySnapshot(options);
if (snapshot is not null)
{
@@ -64,8 +62,6 @@ internal sealed class ClientHeadersAgent : DelegatingAIAgent
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryAgent);
var snapshot = TrySnapshot(options);
if (snapshot is not null)
{
@@ -250,8 +250,6 @@ public sealed class FoundryEvals : IAgentEvaluator
"or set 'includePerAgent: false' so the evaluator only runs on the overall item.");
}
FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryEvals);
// 2. Create the evaluation definition
var createEvalPayload = new WireCreateEvalRequest
{
@@ -452,7 +450,6 @@ public sealed class FoundryEvals : IAgentEvaluator
? evaluators
: [Relevance, Coherence, TaskAdherence];
EnsureAllSpecsValid(resolvedEvaluators, nameof(evaluators));
FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryEvals);
// Create the evaluation definition with the appropriate data source scenario
object dataSourceConfig;
@@ -645,7 +642,6 @@ public sealed class FoundryEvals : IAgentEvaluator
? evaluators
: [Relevance, Coherence, TaskAdherence];
EnsureAllSpecsValid(resolvedEvaluators, nameof(evaluators));
FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryEvals);
var createEvalPayload = new WireCreateEvalRequest
{
@@ -1,12 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Foundry;
internal enum FeatureIndex
{
FoundryChatClient = 48,
FoundryAgent = 49,
FoundryMemory = 50,
FoundryEvals = 51,
FoundryToolbox = 52,
}
@@ -74,7 +74,7 @@ public sealed class FoundryChatClient : DelegatingChatClient
{
this._aiProjectClient = aiProjectClient;
this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: modelId);
_ = FoundryUserAgentPolicies.Registration.TryRegister(this.InnerClient);
TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient);
TryRegisterServedModelPolicy(this.InnerClient);
}
@@ -93,7 +93,7 @@ public sealed class FoundryChatClient : DelegatingChatClient
this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: defaultModelId);
this._baseChatOptions = baseChatOptions;
this.AgentName = agentReference.Name;
_ = FoundryUserAgentPolicies.Registration.TryRegister(this.InnerClient);
TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient);
TryRegisterServedModelPolicy(this.InnerClient);
}
@@ -159,7 +159,7 @@ public sealed class FoundryChatClient : DelegatingChatClient
this._aiProjectClient = inner.AIProjectClient;
this.AgentName = inner.AgentName;
this._metadata = new ChatClientMetadata("microsoft.foundry");
_ = FoundryUserAgentPolicies.Registration.TryRegister(this.InnerClient);
TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient);
TryRegisterServedModelPolicy(this.InnerClient);
}
@@ -211,7 +211,6 @@ public sealed class FoundryChatClient : DelegatingChatClient
var effectiveOptions = this._agentReference is not null
? this.GetAgentEnabledChatOptions(options)
: options;
MarkRequestFeatures(effectiveOptions);
var box = new StrongBox<string?>(null);
var previous = ServedModelScope.Current;
@@ -240,7 +239,6 @@ public sealed class FoundryChatClient : DelegatingChatClient
var effectiveOptions = this._agentReference is not null
? this.GetAgentEnabledChatOptions(options)
: options;
MarkRequestFeatures(effectiveOptions);
var box = new StrongBox<string?>(null);
var previous = ServedModelScope.Current;
@@ -290,7 +288,6 @@ public sealed class FoundryChatClient : DelegatingChatClient
// Use the Stream overload to honor cancellation; the (string, purpose) overload has no
// CancellationToken parameter in the OpenAI SDK.
using var stream = File.OpenRead(filePath);
FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryChatClient);
var result = await fileClient.UploadFileAsync(stream, Path.GetFileName(filePath), purpose, cancellationToken).ConfigureAwait(false);
return result.Value;
}
@@ -304,7 +301,6 @@ public sealed class FoundryChatClient : DelegatingChatClient
{
Throw.IfNullOrWhitespace(fileId);
var fileClient = this.GetOpenAIFileClient();
FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryChatClient);
var result = await fileClient.DeleteFileAsync(fileId, cancellationToken).ConfigureAwait(false);
return result.Value;
}
@@ -377,7 +373,6 @@ public sealed class FoundryChatClient : DelegatingChatClient
}
var vectorStoreClient = this.GetVectorStoreClient();
FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryChatClient);
var createResult = await vectorStoreClient.CreateVectorStoreAsync(options, cancellationToken).ConfigureAwait(false);
var created = createResult.Value;
@@ -450,7 +445,6 @@ public sealed class FoundryChatClient : DelegatingChatClient
{
Throw.IfNullOrWhitespace(vectorStoreId);
var vectorStoreClient = this.GetVectorStoreClient();
FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryChatClient);
var result = await vectorStoreClient.DeleteVectorStoreAsync(vectorStoreId, cancellationToken).ConfigureAwait(false);
return result.Value;
}
@@ -471,25 +465,6 @@ public sealed class FoundryChatClient : DelegatingChatClient
#endregion
private static void MarkRequestFeatures(ChatOptions? options)
{
FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryChatClient);
if (options?.Tools is null)
{
return;
}
foreach (AITool tool in options.Tools)
{
if (tool is HostedMcpToolboxAITool)
{
FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryToolbox);
return;
}
}
}
/// <summary>
/// Parses an agent endpoint URI of shape
/// <c>https://&lt;host&gt;/.../projects/&lt;project&gt;/agents/&lt;agentName&gt;/endpoint/protocols/openai</c>
@@ -671,6 +646,24 @@ public sealed class FoundryChatClient : DelegatingChatClient
return new AgentEndpointInner(chatClient, aiProjectClient, agentName);
}
/// <summary>Best-effort registration of <see cref="AgentFrameworkUserAgentPolicy"/> via the MEAI <see cref="OpenAIRequestPolicies"/> hook with at-most-once dedup per pipeline.</summary>
private static void TryRegisterAgentFrameworkUserAgentPolicy(IChatClient? innerClient)
{
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
if (innerClient?.GetService<OpenAIRequestPolicies>() is { } policies)
{
// OpenAIRequestPoliciesReflection.AddPolicyIfMissing performs a check-then-add against
// the private _entries collection on the OpenAIRequestPolicies instance, so the
// policy is registered at most once even when many FoundryChatClient instances share
// the same underlying chat client.
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
policies,
AgentFrameworkUserAgentPolicy.Instance,
PipelinePosition.PerCall);
}
#pragma warning restore MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
}
/// <summary>
/// Best-effort registration of <see cref="ServedModelPolicy"/> via the MEAI
/// <see cref="OpenAIRequestPolicies"/> hook. The policy captures the
@@ -1,13 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Foundry;
internal static class FoundryFeatureUsage
{
public static void MarkUsed(FeatureIndex feature)
{
#pragma warning disable MAAI001
FeatureUsage.MarkUsed((int)feature);
#pragma warning restore MAAI001
}
}
@@ -94,7 +94,6 @@ public sealed class FoundryMemoryProvider : AIContextProvider
protected override async ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
Throw.IfNull(context);
FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryMemory);
State state = this._sessionState.GetOrInitializeState(context.Session);
FoundryMemoryProviderScope scope = state.Scope;
@@ -182,8 +181,6 @@ public sealed class FoundryMemoryProvider : AIContextProvider
/// <inheritdoc />
protected override async ValueTask StoreAIContextAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryMemory);
State state = this._sessionState.GetOrInitializeState(context.Session);
FoundryMemoryProviderScope scope = state.Scope;
@@ -254,8 +251,6 @@ public sealed class FoundryMemoryProvider : AIContextProvider
public async Task EnsureStoredMemoriesDeletedAsync(AgentSession session, CancellationToken cancellationToken = default)
{
Throw.IfNull(session);
FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryMemory);
State state = this._sessionState.GetOrInitializeState(session);
FoundryMemoryProviderScope scope = state.Scope;
@@ -297,8 +292,6 @@ public sealed class FoundryMemoryProvider : AIContextProvider
string? description = null,
CancellationToken cancellationToken = default)
{
FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryMemory);
bool created = await this._client.CreateMemoryStoreIfNotExistsAsync(
this._memoryStoreName,
description,
@@ -341,8 +334,6 @@ public sealed class FoundryMemoryProvider : AIContextProvider
TimeSpan? pollingInterval = null,
CancellationToken cancellationToken = default)
{
FoundryFeatureUsage.MarkUsed(FeatureIndex.FoundryMemory);
string? updateId = Volatile.Read(ref this._lastPendingUpdateId);
if (updateId is null)
{
@@ -5,7 +5,6 @@
(ProjectOpenAIClientOptions.AgentName, the (AuthenticationPolicy, options) ctor, and
related per-agent endpoint surface). Flip back to IsReleased=true once Azure.AI.Projects
ships a stable 2.1.0. -->
<InjectSharedFeatureUsageUserAgent>true</InjectSharedFeatureUsageUserAgent>
<InjectSharedThrow>true</InjectSharedThrow>
</PropertyGroup>
@@ -1,18 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.GitHub.Copilot;
internal enum FeatureIndex
{
GitHubCopilot = 57,
}
internal static class FeatureUsageMarker
{
public static void MarkUsed()
{
#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
FeatureUsage.MarkUsed((int)FeatureIndex.GitHubCopilot);
#pragma warning restore MAAI001
}
}
@@ -172,8 +172,6 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
$"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(GitHubCopilotAgentSession)}' can be used by this agent.");
}
FeatureUsageMarker.MarkUsed();
// Ensure the client is started
await this.EnsureClientStartedAsync(cancellationToken).ConfigureAwait(false);
@@ -333,8 +331,6 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
{
Model = source?.Model,
ReasoningEffort = source?.ReasoningEffort,
ReasoningSummary = source?.ReasoningSummary,
ContextTier = source?.ContextTier,
Tools = source?.Tools,
SystemMessage = source?.SystemMessage,
AvailableTools = source?.AvailableTools,

Some files were not shown because too many files have changed in this diff Show More