Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions dotnet/agent-framework-durable-extension.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
<Project Path="samples/DurableAgents/ConsoleApps/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj" />
<Project Path="samples/DurableAgents/ConsoleApps/06_LongRunningTools/06_LongRunningTools.csproj" />
<Project Path="samples/DurableAgents/ConsoleApps/07_ReliableStreaming/07_ReliableStreaming.csproj" />
<Project Path="samples/DurableAgents/ConsoleApps/08_FoundryManagedAgent/08_FoundryManagedAgent.csproj" />
<Project Path="samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/09_CustomHistoryProvider.csproj" />
<Project Path="samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider/tests/09_CustomHistoryProvider.Tests.csproj" />
</Folder>
<Folder Name="/samples/DurableAgents/AzureFunctions/">
<Project Path="samples/DurableAgents/AzureFunctions/01_SingleAgent/01_SingleAgent.csproj" />
Expand Down
42 changes: 42 additions & 0 deletions dotnet/eng/verify-samples/DurableAgentSamples.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ internal static class DurableAgentSamples
{
private const string AzureFunctionsSkipReason =
"Requires Azure Functions Core Tools runtime and starts a web host.";
private const string ExperimentalMailboxSkipReason =
"Draft only: schema 2 mailbox writes remain behind an internal, default-disabled rollout gate.";

public static IReadOnlyList<SampleDefinition> ConsoleApps { get; } =
[
Expand Down Expand Up @@ -91,6 +93,46 @@ internal static class DurableAgentSamples
"The output should not contain error messages or stack traces.",
],
},
new SampleDefinition
{
Name = "DurableAgents_Console_08_FoundryManagedAgent",
ProjectPath = "samples/DurableAgents/ConsoleApps/08_FoundryManagedAgent",
SkipReason = ExperimentalMailboxSkipReason,
RequiredEnvironmentVariables =
[
"FOUNDRY_PROJECT_ENDPOINT",
"FOUNDRY_MODEL",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING",
],
MustContain =
[
"Host stopped. Starting a new host to force durable state restoration.",
"Marker recall after durable restart: PASS",
"The fixed service-owner binding and restored service conversation identity are verified by deterministic tests.",
"Deleted Foundry agent version:",
],
IsDeterministic = true,
},
new SampleDefinition
{
Name = "DurableAgents_Console_09_CustomHistoryProvider",
ProjectPath = "samples/DurableAgents/ConsoleApps/09_CustomHistoryProvider",
SkipReason = ExperimentalMailboxSkipReason,
RequiredEnvironmentVariables =
[
"FOUNDRY_PROJECT_ENDPOINT",
"FOUNDRY_MODEL",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING",
],
Inputs = ["SAMPLE-MARKER-09"],
ExpectedOutputDescription =
[
"The output should state that cumulative external history exceeds 1 MiB using moderate records, not one oversized durable request.",
"The output should show a bounded provider-supplied model-history window.",
"The output should show that the marker and the same logical external history reference survive a host restart.",
"The output should not contain error messages or stack traces.",
],
},
];

public static IReadOnlyList<SampleDefinition> AzureFunctions { get; } =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>FoundryManagedAgent</AssemblyName>
<RootNamespace>FoundryManagedAgent</RootNamespace>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Agents.AI.Foundry" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>

<!-- Local project that should be switched to a package reference when using the sample outside of this repo. -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// Copyright (c) Microsoft. All rights reserved.

using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.Foundry;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

const string FoundryServiceHistoryProviderKey = "foundry-managed-service.v1";

if (!IsExperimentalMailboxRuntimeAvailable())
{
Console.Error.WriteLine(
"DRAFT SAMPLE: schema 2 mailbox writes are protected by an internal, default-disabled rollout gate. " +
"This sample cannot run against production or mixed-language runtimes yet.");
return 2;
}

string projectEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL")
?? throw new InvalidOperationException("FOUNDRY_MODEL is not set.");
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? throw new InvalidOperationException("DURABLE_TASK_SCHEDULER_CONNECTION_STRING is not set.");

// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
DefaultAzureCredential credential = new();
AIProjectClient projectClient = new(new Uri(projectEndpoint), credential);

string agentName = $"durable-foundry-{Guid.NewGuid():N}"[..24];
ProjectsAgentVersion? createdVersion = null;
IHost? host = null;
int exitCode = 0;

try
{
createdVersion = await projectClient.AgentAdministrationClient.CreateAgentVersionAsync(
agentName,
new ProjectsAgentVersionCreationOptions(
new DeclarativeAgentDefinition(deploymentName)
{
Instructions =
"""
You are a conversation continuity test agent.
When asked to remember a marker, acknowledge it and retain it.
When later asked for the marker only, respond with exactly that marker and no other text.
""",
}));

FoundryAgent foundryAgent = CreateFoundryAgent(projectClient, createdVersion);
host = CreateHost(foundryAgent, dtsConnectionString);
await host.StartAsync();

AIAgent durableAgent = host.Services.GetRequiredKeyedService<AIAgent>(foundryAgent.Name!);
DurableAgentSession durableSession =
(DurableAgentSession)await durableAgent.CreateSessionAsync();

string marker = $"durable-{Guid.NewGuid():N}"[..16];
Console.WriteLine("=== Durable Foundry Managed Agent Sample ===");
Console.WriteLine($"Foundry agent version: {createdVersion.Name}/{createdVersion.Version}");
Console.WriteLine($"Durable session: {durableSession.GetService<AgentSessionId>()}");

AgentResponse first = await durableAgent.RunAsync(
$"Remember this marker for our conversation: {marker}. Confirm the marker in your reply.",
durableSession);
Console.WriteLine($"First response: {first.Text}");

if (!first.Text.Contains(marker, StringComparison.Ordinal))
{
throw new InvalidOperationException("The first response did not confirm the generated marker.");
}

IHost firstHost = host;
host = null;
await StopAndDisposeHostAsync(firstHost);
Console.WriteLine("Host stopped. Starting a new host to force durable state restoration.");

FoundryAgent restoredFoundryAgent = CreateFoundryAgent(projectClient, createdVersion);
host = CreateHost(restoredFoundryAgent, dtsConnectionString);
await host.StartAsync();

AIAgent restoredDurableAgent =
host.Services.GetRequiredKeyedService<AIAgent>(restoredFoundryAgent.Name!);
AgentResponse second = await restoredDurableAgent.RunAsync(
"Return the marker only.",
durableSession);
Console.WriteLine($"Second response: {second.Text}");

if (!second.Text.Contains(marker, StringComparison.Ordinal))
{
throw new InvalidOperationException(
"Marker recall check failed: the second response did not contain the generated marker.");
}

Console.WriteLine("Marker recall after durable restart: PASS");
Console.WriteLine(
"The fixed service-owner binding and restored service conversation identity are verified by deterministic tests.");
}
catch (Exception ex)
{
exitCode = 1;
Console.Error.WriteLine($"Sample failed: {ex}");
}
finally
{
if (host is not null)
{
try
{
await StopAndDisposeHostAsync(host);
}
catch (Exception ex)
{
exitCode = 1;
Console.Error.WriteLine($"Failed to stop the durable host cleanly: {ex.Message}");
}
}

if (createdVersion is not null)
{
try
{
await projectClient.AgentAdministrationClient.DeleteAgentVersionAsync(
createdVersion.Name,
createdVersion.Version);
Console.WriteLine($"Deleted Foundry agent version: {createdVersion.Name}/{createdVersion.Version}");
}
catch (Exception ex)
{
exitCode = 1;
Console.Error.WriteLine(
$"Failed to delete Foundry agent version {createdVersion.Name}/{createdVersion.Version}: {ex.Message}");
}
}
}

return exitCode;

static FoundryAgent CreateFoundryAgent(
AIProjectClient projectClient,
ProjectsAgentVersion agentVersion)
{
FoundryAgent agent = projectClient.AsAIAgent(agentVersion);

_ = agent.GetService<ChatClientAgent>()
?? throw new InvalidOperationException(
"The FoundryAgent did not expose its inner ChatClientAgent pipeline.");

return agent;
}

static IHost CreateHost(AIAgent foundryAgent, string dtsConnectionString)
{
return Host.CreateDefaultBuilder()
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
services.ConfigureDurableAgents(
options =>
{
options.AddAIAgent(foundryAgent, timeToLive: TimeSpan.FromHours(1));
options.SetHistoryProviderKey(
foundryAgent.Name!,
FoundryServiceHistoryProviderKey);
options.HistoryRetentionMode = DurableAgentHistoryRetentionMode.KeepAll;

// FoundryAgent's versioned-agent path does not enable
// RequirePerServiceCallChatHistoryPersistence. Once the first response supplies
// a service conversation ID, this C# runtime binds the durable session to the
// logical Foundry service owner and restores that opaque continuation on restart.
},
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();
}

static bool IsExperimentalMailboxRuntimeAvailable() => false;

static async Task StopAndDisposeHostAsync(IHost host)
{
try
{
await host.StopAsync();
}
finally
{
host.Dispose();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Durable Foundry Managed Agent

This sample wraps a server-managed, versioned Microsoft Foundry agent as a Microsoft Agent Framework `FoundryAgent`, registers it as a durable `AIAgent`, and restores its opaque service continuation across a host restart.

> [!WARNING]
> This is a draft, experimental sample. Schema 2 mailbox writes remain behind an internal,
> default-disabled C# runtime gate, so the executable exits before creating any Foundry resource.
> Do not enable this scenario in mixed-runtime production until Python, the dashboard, pollers, and
> every other reader meet the schema 2 rollout floor.

## Architecture

```text
Console application
|
| keyed AIAgent proxy + DurableAgentSession
v
Durable Task Scheduler
|-- owns invocation, retry, durable entity state, and entity lifetime
|-- binds the durable session to a stable logical service-owner key
|-- persists execution/delivery records and the opaque inner AgentSession
v
FoundryAgent -> ChatClientAgent -> Foundry Responses API
|-- owns the versioned agent definition
`-- owns the server-side conversation and its transcript
```

The application:

1. Creates a uniquely named Foundry agent version with `CreateAgentVersionAsync`.
2. Wraps that exact version with `AIProjectClient.AsAIAgent`, producing a `FoundryAgent`.
3. Registers the `FoundryAgent` with `ConfigureDurableAgents`, keeps `HistoryRetentionMode.KeepAll`, and sets the stable logical key `foundry-managed-service.v1`. Deterministic tests activate schema 2 through the runtime's internal test hook; the sample does not expose or bypass that gate.
4. Resolves the keyed durable `AIAgent` proxy and creates a `DurableAgentSession`.
5. Sends a generated marker on the first turn. Foundry establishes the service conversation, and the durable entity persists the inner `ChatClientAgent` session containing that conversation identity.
6. Stops the host, creates a fresh `FoundryAgent` wrapper and host, and sends a second turn using the same `DurableAgentSession`. The durable entity restores the opaque inner session. The deterministic registration test verifies that the same service conversation ID survives serialization and restoration; the live marker recall is an additional smoke signal, not the proof of identity.
7. Stops the host before deleting the exact server agent version in `finally`.

The host restart is intentional: it demonstrates that continuity comes from durable entity state, not from an in-memory `FoundryAgent` or `AgentSession`.

## History ownership

After Foundry establishes a conversation, Foundry owns the transcript. The durable entity keeps the fixed logical owner binding, execution/delivery records, and opaque inner-session continuation. It does not keep or replay a duplicate transcript in `conversationHistory`.

Schema 2 terminal results and completion receipts are required because the service-owned transcript is not mirrored into durable state. Activation is deliberately unavailable through the public options API. The deterministic tests use the existing internal test hook, while this sample remains gated and retains `HistoryRetentionMode.KeepAll` rather than opting into automatic transcript eviction.

This fixed ownership is an intentional C# durable runtime contract. `HistorySampleRegistrationTests.FoundryAgentRegistrationRestoresServiceContinuationAcrossProxyRestartAsync` invokes the actual versioned `FoundryAgent` through the registered durable proxy and verifies the entity-to-service transition, mailbox-only state, cold-host continuation, and a second call without credentials or network access. `FoundryAgentRegistrationTests.ServerManagedFoundryAgentRestoresFixedServiceOwnershipAsync` separately verifies wrapper discovery and the stable provider key. `AgentEntityHistoryTests.ServiceManagedConversationStoresOnlyMailboxAndContinuationAsync`, `AgentEntityHistoryTests.FirstServiceManagedTurnDoesNotLeaveEntityOwnedTranscriptAsync`, and `AgentEntityHistoryTests.WrappedServerManagedSessionSurvivesColdEntityInvocationAsync` cover the durable entity state shape and cold continuation.

This versioned `FoundryAgent` path does **not** enable `ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence`, so the sample intentionally does not call `SetServiceManagedPerServiceCallHistory`. That declaration is only needed when a service-backed `ChatClientAgent` explicitly enables per-service-call persistence; it is otherwise ignored.

For arbitrary server-hosted `AIAgent` implementations that do not expose a discoverable `ChatClientAgent` pipeline, configure `DurableAgentHistoryReplayMode.CurrentRequestOnly` explicitly. This sample does not need that fallback because `FoundryAgent` exposes its inner `ChatClientAgent` through `GetService<ChatClientAgent>()`.

## Prerequisites and configuration

- .NET 10 SDK
- Azure CLI authenticated with `az login`
- Permission to create, invoke, and delete agents in the Foundry project
- A Durable Task Scheduler endpoint, such as the local DTS emulator

Set:

```powershell
$env:FOUNDRY_PROJECT_ENDPOINT = "https://<resource>.services.ai.azure.com/api/projects/<project>"
$env:FOUNDRY_MODEL = "<OpenAI-model-deployment>"
$env:DURABLE_TASK_SCHEDULER_CONNECTION_STRING = "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
```

Under this sample's model policy, configure an OpenAI model deployment that is supported by Foundry prompt agents and the Responses API. No credentials are stored in tracked files. `DefaultAzureCredential` is convenient for local development; production applications should prefer a specific credential such as `ManagedIdentityCredential` to avoid unintended credential probing and latency.

## Run

The source is retained as the intended end-to-end flow, but it is not currently runnable. Invoking it
prints the draft-gate message and exits before reading credentials, creating a Foundry agent version,
or contacting DTS:

```powershell
cd dotnet\samples\DurableAgents\ConsoleApps\08_FoundryManagedAgent
dotnet run --framework net10.0
```

Once the cross-runtime rollout floor is met and the production gate is approved, a successful run is
expected to print both responses and `Marker recall after durable restart: PASS`. Model wording is
nondeterministic. The marker check is only a smoke signal; the deterministic tests prove the fixed
binding and restored service conversation ID.

## Cleanup

The durable host is stopped before cleanup. Because the sample creates a unique server agent name, its `finally` block deletes only the exact version it created. Cleanup failures are reported and cause a failed process exit when there was no earlier failure; they never hide the original sample failure.

## Known limitation

A caller still cannot seed a new `DurableAgentSession` from an existing, pre-durable Foundry conversation. That unsupported scenario is isolated in the [server-managed agent reproduction branch](https://github.com/microsoft/agent-framework-durable-extension/tree/tamirdresher-microsoft-server-managed-agent-repro).
Loading
Loading