Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7433aab92b |
+3
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}}"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) ===",
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
|
||||
+2
-2
@@ -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:
|
||||
|
||||
@@ -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.|
|
||||
|
||||
-1
@@ -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>
|
||||
|
||||
+20
-14
@@ -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,
|
||||
|
||||
+8
-12
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
-40
@@ -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
|
||||
+1
-1
@@ -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>
|
||||
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
|
||||
-22
@@ -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-*/
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
# Optional local countdown delay
|
||||
COUNTDOWN_DELAY_SECONDS=1
|
||||
|
||||
# Local development only
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
-38
@@ -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>
|
||||
-137
@@ -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);
|
||||
}
|
||||
-117
@@ -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.
|
||||
-37
@@ -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
|
||||
-22
@@ -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-*/
|
||||
-9
@@ -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
|
||||
-40
@@ -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>
|
||||
-63
@@ -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))!;
|
||||
-112
@@ -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.
|
||||
-36
@@ -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
|
||||
+4
-20
@@ -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)
|
||||
|
||||
+1
-7
@@ -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).
|
||||
-1
@@ -12,7 +12,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Core" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
-91
@@ -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);
|
||||
}
|
||||
-971
@@ -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;
|
||||
}
|
||||
}
|
||||
-103
@@ -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`.
|
||||
-26
@@ -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
|
||||
|
||||
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
-101
@@ -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.")));
|
||||
}
|
||||
}
|
||||
-2
@@ -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://<host>/.../projects/<project>/agents/<agentName>/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
Reference in New Issue
Block a user