Skip to content
Merged
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
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 workflow turn contains one message; add 10 to its countdown start value.
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