Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions dotnet/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
<PackageVersion Include="Mscc.GenerativeAI.Microsoft" Version="2.9.3" />
<!-- Microsoft.Azure.* -->
<PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.61.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.11" />
<!-- Newtonsoft.Json -->
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<!-- System.* -->
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
.azure
azure.yaml
azure.yaml
Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# Optional local countdown delay
COUNTDOWN_DELAY_SECONDS=1

# Local development only
# Local development only
ASPNETCORE_URLS=http://+:8088
IDEMPOTENT_SERVICE_ENDPOINT=http://localhost:8089/
IDEMPOTENT_OPERATION_SCOPE=local-countdown
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
<UseLocalAgentFramework Condition="'$(UseLocalAgentFramework)' == '' and Exists('$(LocalAgentFrameworkRoot)\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj')">true</UseLocalAgentFramework>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
Comment thread
rogerbarreto marked this conversation as resolved.
</ItemGroup>

<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" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

// 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.
// process resumes without losing confirmed output. An interrupted in-flight step can run again.

using System.Globalization;
using System.Text.RegularExpressions;
using DotNetEnv;
using Hosted_Shared_Contributor_Setup;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Agents.AI.Workflows;
Expand All @@ -16,20 +16,16 @@

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 idempotentServiceEndpoint = System.Environment.GetEnvironmentVariable("IDEMPOTENT_SERVICE_ENDPOINT")
?? throw new InvalidOperationException("IDEMPOTENT_SERVICE_ENDPOINT is not set.");
var operationScope = System.Environment.GetEnvironmentVariable("IDEMPOTENT_OPERATION_SCOPE")
?? throw new InvalidOperationException("IDEMPOTENT_OPERATION_SCOPE is not set.");

using var idempotentServiceHttpClient = new HttpClient { BaseAddress = new Uri(idempotentServiceEndpoint) };
var idempotentService = new IdempotentServiceClient(idempotentServiceHttpClient);

var start = new CountdownStartExecutor();
var countdown = new CountdownExecutor(TimeSpan.FromSeconds(delaySeconds));
var countdown = new CountdownExecutor(idempotentService, operationScope);
var complete = new CountdownCompleteExecutor();

Workflow workflow = new WorkflowBuilder(start)
Expand All @@ -46,60 +42,98 @@
includeWorkflowOutputsInResponse: true);

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(
agent,
configure: options => options.ResilientBackground = true);
builder.Services.AddFoundryResponses(agent, configure: options => options.ResilientBackground = true);

var app = builder.Build();
app.MapFoundryResponses();
Task? requestedShutdown = null;
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}

// This configuration is for local development demonstration purposes only.
// When hosted in Foundry the lifetime of the agent process is managed and shutdowns are handled gracefully.
if (app.Environment.IsDevelopment()
&& string.Equals(
System.Environment.GetEnvironmentVariable("ENABLE_E2E_SHUTDOWN_ENDPOINT"),
"true",
StringComparison.OrdinalIgnoreCase))
{
app.MapPost("/shutdown", (IEnumerable<IHostedService> hostedServices) =>
{
// The E2E closes its client stream first, then signals the resilient task service before
// stopping HTTP. This reproduces the hosted-service shutdown path used by AgentServer tests.
IHostedService taskDurabilityService = hostedServices.SingleOrDefault(
service => string.Equals(
service.GetType().FullName,
"Azure.AI.AgentServer.Core.Tasks.Engine.TaskDurabilityService",
StringComparison.Ordinal))
?? throw new InvalidOperationException("The AgentServer resilient task service is not registered.");
#pragma warning disable CA2025 // Awaited after app.RunAsync before top-level disposable resources are disposed.
requestedShutdown ??= StopServerAsync(app, taskDurabilityService);
#pragma warning restore CA2025
return Results.Accepted();
});
}

Console.WriteLine($"Process ID: {System.Environment.ProcessId}");
app.Run();
await app.RunAsync();
if (requestedShutdown is not null)
{
await requestedShutdown;
}

static async Task StopServerAsync(WebApplication app, IHostedService taskDurabilityService)
{
await Task.Delay(TimeSpan.FromMilliseconds(100));
await taskDurabilityService.StopAsync(CancellationToken.None);
await app.StopAsync();
}

/// <summary>
/// Starts the countdown ten above the numeric input so the E2E can interrupt it after some progress.
/// </summary>
[SendsMessage(typeof(int))]
[YieldsOutput(typeof(string))]
internal sealed partial class CountdownStartExecutor() : ChatProtocolExecutor(
"start",
new ChatProtocolExecutorOptions { AutoSendTurnToken = false })
internal sealed class CountdownStartExecutor() : ChatProtocolExecutor(
"start", new ChatProtocolExecutorOptions { AutoSendTurnToken = false })
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
base.ConfigureProtocol(protocolBuilder).SendsMessage<int>();

protected override async ValueTask TakeTurnAsync(
protected override 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;
}
// The first turn of the workflow is a single message that contains the countdown start value with added of 10 units.
Comment thread
rogerbarreto marked this conversation as resolved.
Outdated
var maxNumberOfMessages = 10 + int.Parse(messages.Single().Text, CultureInfo.InvariantCulture);

await context.SendMessageAsync(target, cancellationToken: cancellationToken);
return context.SendMessageAsync(maxNumberOfMessages, cancellationToken: cancellationToken);
}

[GeneratedRegex(@"(?<!\d)\d+(?!\d)", RegexOptions.CultureInvariant)]
private static partial Regex PositiveIntegerRegex();
}

/// <summary>
/// Calls the idempotent service for each count, yields its result, and schedules the next count.
/// </summary>
/// <remarks>
/// <para>
/// For demonstration purposes only. The workflow and its backing service should not be used as-is in production.
/// </para>
/// <para>
/// A service call can finish before the workflow and response checkpoints are confirmed.
/// If the process stops during that interval, recovery can call the service again for the same count.
/// Reusing the scope and operation ID lets the service return the stored result without repeating its effect.
/// Checkpoint recovery and stream replay do not undo effects already performed by downstream services.
/// </para>
/// </remarks>
[SendsMessage(typeof(int))]
[SendsMessage(typeof(string))]
[YieldsOutput(typeof(string))]
internal sealed class CountdownExecutor(TimeSpan delay) : Executor<int>("countdown")
internal sealed class CountdownExecutor(
IdempotentServiceClient idempotentService,
string operationScope) : Executor<int>("countdown")
{
public override async ValueTask HandleAsync(
int message,
Expand All @@ -108,30 +142,22 @@ public override async ValueTask HandleAsync(
{
if (message <= 0)
{
await context.SendMessageAsync(
"Countdown complete.",
targetId: "complete",
cancellationToken: cancellationToken);
await context.SendMessageAsync(string.Empty, 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);
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
string result = await idempotentService.ExecuteOperationAsync(operationScope, message, cancellationToken);
await context.YieldOutputAsync(result, cancellationToken);
await context.SendMessageAsync(message - 1, targetId: this.Id, cancellationToken: cancellationToken);
}
}

[YieldsOutput(typeof(string))]
internal sealed class CountdownCompleteExecutor() : Executor<string>("complete")
internal sealed class CountdownCompleteExecutor() : Executor<string, string>("complete")
{
public override ValueTask HandleAsync(
public override ValueTask<string> HandleAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default) =>
context.YieldOutputAsync(message, cancellationToken);
ValueTask.FromResult(message);
}
Loading
Loading