Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright (c) Microsoft. All rights reserved.

using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Agents.AI.Hosting.AzureFunctions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker;
using Microsoft.DurableTask.Client;

namespace SequentialWorkflow;

/// <summary>
/// Demonstrates invoking a registered workflow from your own function code.
/// </summary>
/// <remarks>
/// <para>
/// These functions are hand-written endpoints that sit in front of the <c>CancelOrder</c> workflow,
/// rather than the endpoints the framework generates. Instead of creating an <see cref="HttpClient"/>
/// and POSTing to <c>workflows/CancelOrder/run</c>, they get an <see cref="IWorkflowClient"/> from the
/// <c>[DurableClient]</c> binding and start the workflow by name.
Comment thread
cgillum marked this conversation as resolved.
/// </para>
/// <para>
/// The workflow is started through the durable backend rather than through the workflow's generated
/// HTTP route, so the call path does not depend on that route and the same two lines work from any
/// trigger type - queue, timer, Event Grid, Service Bus.
/// </para>
/// </remarks>
public sealed class OrderFunctions
{
private const string CancelOrderWorkflow = "CancelOrder";

/// <summary>
/// Starts the <c>CancelOrder</c> workflow and returns its run ID immediately.
/// </summary>
/// <param name="request">The HTTP request.</param>
/// <param name="orderId">The order to cancel, taken from the route.</param>
/// <param name="durableClient">The Durable Task client provided by the <c>[DurableClient]</c> binding.</param>
/// <param name="context">The function invocation context.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A <c>202 Accepted</c> response carrying the workflow run ID.</returns>
[Function(nameof(CancelOrderAsync))]
public async Task<IActionResult> CancelOrderAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "orders/{orderId}/cancel")] HttpRequest request,
string orderId,
[DurableClient] DurableTaskClient durableClient,
FunctionContext context,
CancellationToken cancellationToken)
{
// Get a workflow client from the durable client binding. No endpoint URI, no HttpClient.
IWorkflowClient workflows = durableClient.AsWorkflowClient(context);

// Start the registered workflow by name. The Workflow object built in Program.cs is not needed here.
IWorkflowRun run = await workflows.RunAsync(CancelOrderWorkflow, orderId, cancellationToken: cancellationToken);

return new AcceptedResult(location: null, value: $"Workflow orchestration started for {CancelOrderWorkflow}. Orchestration runId: {run.RunId}");
}

/// <summary>
/// Starts the <c>CancelOrder</c> workflow and waits for it to finish before responding.
/// </summary>
/// <param name="request">The HTTP request.</param>
/// <param name="orderId">The order to cancel, taken from the route.</param>
/// <param name="durableClient">The Durable Task client provided by the <c>[DurableClient]</c> binding.</param>
/// <param name="context">The function invocation context.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A <c>200 OK</c> response carrying the workflow result, or <c>202 Accepted</c> carrying the run ID if the run handle does not support awaiting completion.</returns>
[Function(nameof(CancelOrderAndWaitAsync))]
public async Task<IActionResult> CancelOrderAndWaitAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "orders/{orderId}/cancel-and-wait")] HttpRequest request,
string orderId,
[DurableClient] DurableTaskClient durableClient,
FunctionContext context,
CancellationToken cancellationToken)
{
IWorkflowClient workflows = durableClient.AsWorkflowClient(context);

IWorkflowRun run = await workflows.RunAsync(CancelOrderWorkflow, orderId, cancellationToken: cancellationToken);

// Durable workflow runs also implement IAwaitableWorkflowRun, so the same handle can be
// awaited for the final result.
if (run is not IAwaitableWorkflowRun awaitableRun)
{
return new AcceptedResult(location: null, value: run.RunId);
}

string? result = await awaitableRun.WaitForCompletionAsync<string>(cancellationToken);
return new OkObjectResult(result);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
// The OrderStatus workflow looks up an order and generates a status report.
// The BatchCancelOrders workflow accepts a complex JSON input to cancel multiple orders.
// Both CancelOrder and OrderStatus reuse the same OrderLookup executor, demonstrating executor sharing.
// OrderFunctions.cs additionally shows how to start a registered workflow from your own function
// code using DurableTaskClient.AsWorkflowClient, without creating an HttpClient.

using Microsoft.Agents.AI.Hosting.AzureFunctions;
using Microsoft.Agents.AI.Workflows;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ This sample demonstrates how to use the Microsoft Agent Framework to create an A
- Registering workflows with the Function app using `ConfigureDurableWorkflows`
- Durable orchestration ensuring workflows survive process restarts and failures
- Starting workflows via HTTP requests
- Invoking a workflow from your own function code with `DurableTaskClient.AsWorkflowClient`, without an `HttpClient`
- Viewing workflow execution history and status in the Durable Task Scheduler (DTS) dashboard

## Workflows
Expand Down Expand Up @@ -140,6 +141,38 @@ The `OrderStatus` workflow reuses the same `OrderLookup` executor and then gener
│ [Activity] StatusReport: ✓ Order 12345 for Jerry: Status=Active, Date=2025-01-01
```

### Invoking a Workflow from Function Code

The endpoints above are generated by the framework. `OrderFunctions.cs` shows the other direction: hand-written functions that start the same `CancelOrder` workflow from your own code, without creating an `HttpClient` or knowing the workflow's HTTP route. This alternative approach is useful when you want to start a workflow from a queue trigger, timer trigger, etc. or if you need more control over the HTTP request/response than the generated endpoints provide.

```csharp
// Get a workflow client from the [DurableClient] binding, then start the workflow by name.
IWorkflowClient workflows = durableClient.AsWorkflowClient(context);
IWorkflowRun run = await workflows.RunAsync("CancelOrder", orderId);
```

The workflow is started through the durable backend rather than through the workflow's generated HTTP route, so the call path does not depend on that route and the same two lines work from any trigger type — queue, timer, Event Grid, Service Bus. If the workflow name is not registered, `RunAsync` throws `WorkflowNotRegisteredException` immediately instead of scheduling an orchestration that no worker can execute.

Start the workflow and get back a run ID:

```bash
curl -X POST http://localhost:7071/api/orders/12345/cancel
```

```text
Workflow orchestration started for CancelOrder. Orchestration runId: abc123def456
```

Durable workflow runs also implement `IAwaitableWorkflowRun`, so the same handle can be awaited for the final result:

```bash
curl -X POST http://localhost:7071/api/orders/12345/cancel-and-wait
```

```text
Cancellation email sent for order 12345 to jerry@example.com.
```

### Viewing Workflows in the DTS Dashboard

After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to visualize the completed orchestration, inspect inputs/outputs for each step, and view execution history.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,9 @@ POST {{authority}}/api/workflows/BatchCancelOrders/run
Content-Type: application/json

{"orderIds": ["1001", "1002", "1003"], "reason": "Customer requested cancellation", "notifyCustomers": true}

### Cancel an order from function code (see OrderFunctions.cs), returning the run ID
POST {{authority}}/api/orders/12345/cancel

### Cancel an order from function code and wait for the result
POST {{authority}}/api/orders/12345/cancel-and-wait
1 change: 1 addition & 0 deletions dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## [Unreleased]

- [BREAKING] Added `IWorkflowClient` overloads that start a registered workflow by name, and made workflow result deserialization case-insensitive so results can be read back when hosted in Azure Functions. External implementations of `IWorkflowClient` must implement the new members, and an untyped `null` first argument is now ambiguous between the `Workflow` and workflow-name overloads ([#48](https://github.com/microsoft/agent-framework-durable-extension/pull/48))
- [BREAKING] Removed the `AddAIAgents` and `AddWorkflows` bulk registration APIs and changed `AddWorkflow` to return `DurableWorkflowOptions` so multiple workflows can be registered fluently ([#39](https://github.com/microsoft/agent-framework-durable-extension/pull/39))
- Use "session" instead of "thread" terminology in documentation and API comments ([#47](https://github.com/microsoft/agent-framework-durable-extension/pull/47))
- Wrap RequestPort external responses in a controlled `DurableExecutorOutput` envelope so that only the `result` property is populated during deserialization ([#20](https://github.com/microsoft/agent-framework-durable-extension/pull/20))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright (c) Microsoft. All rights reserved.

namespace Microsoft.Agents.AI.DurableTask;

/// <summary>
/// Exception thrown when a workflow with the specified name has not been registered.
/// </summary>
public sealed class WorkflowNotRegisteredException : InvalidOperationException
{
// Not used, but required by static analysis.
private WorkflowNotRegisteredException()
{
this.WorkflowName = string.Empty;
}

/// <summary>
/// Initializes a new instance of the <see cref="WorkflowNotRegisteredException"/> class with the workflow name.
/// </summary>
/// <param name="workflowName">The name of the workflow that was not registered.</param>
public WorkflowNotRegisteredException(string workflowName)
: base(GetMessage(workflowName))
{
this.WorkflowName = workflowName;
}

/// <summary>
/// Initializes a new instance of the <see cref="WorkflowNotRegisteredException"/> class with the workflow name and an inner exception.
/// </summary>
/// <param name="workflowName">The name of the workflow that was not registered.</param>
/// <param name="innerException">The exception that is the cause of the current exception.</param>
public WorkflowNotRegisteredException(string workflowName, Exception? innerException)
: base(GetMessage(workflowName), innerException)
{
this.WorkflowName = workflowName;
}

/// <summary>
/// Gets the name of the workflow that was not registered.
/// </summary>
public string WorkflowName { get; }

private static string GetMessage(string workflowName)
{
ArgumentException.ThrowIfNullOrEmpty(workflowName);
return $"No workflow named '{workflowName}' was registered. Ensure the workflow is registered using {nameof(ServiceCollectionExtensions.ConfigureDurableWorkflows)} or {nameof(ServiceCollectionExtensions.ConfigureDurableOptions)} before invoking it by name.";
}
Comment thread
Copilot marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,20 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows;
internal sealed class DurableWorkflowClient : IWorkflowClient
{
private readonly DurableTaskClient _client;
private readonly DurableOptions _options;

/// <summary>
/// Initializes a new instance of the <see cref="DurableWorkflowClient"/> class.
/// </summary>
/// <param name="client">The durable task client for orchestration operations.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> is null.</exception>
public DurableWorkflowClient(DurableTaskClient client)
/// <param name="options">The durable options containing the registered workflows.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> or <paramref name="options"/> is null.</exception>
public DurableWorkflowClient(DurableTaskClient client, DurableOptions options)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(options);
this._client = client;
this._options = options;
}

/// <inheritdoc/>
Expand Down Expand Up @@ -59,6 +63,23 @@ public ValueTask<IWorkflowRun> RunAsync(
CancellationToken cancellationToken = default)
=> this.RunAsync<string>(workflow, input, runId, cancellationToken);

/// <inheritdoc/>
public ValueTask<IWorkflowRun> RunAsync<TInput>(
string workflowName,
TInput input,
string? runId = null,
CancellationToken cancellationToken = default)
where TInput : notnull
=> this.RunAsync(this.ResolveWorkflow(workflowName), input, runId, cancellationToken);

/// <inheritdoc/>
public ValueTask<IWorkflowRun> RunAsync(
string workflowName,
string input,
string? runId = null,
CancellationToken cancellationToken = default)
=> this.RunAsync<string>(workflowName, input, runId, cancellationToken);

/// <inheritdoc/>
public async ValueTask<IStreamingWorkflowRun> StreamAsync<TInput>(
Workflow workflow,
Expand Down Expand Up @@ -92,4 +113,41 @@ public ValueTask<IStreamingWorkflowRun> StreamAsync(
string? runId = null,
CancellationToken cancellationToken = default)
=> this.StreamAsync<string>(workflow, input, runId, cancellationToken);

/// <inheritdoc/>
public ValueTask<IStreamingWorkflowRun> StreamAsync<TInput>(
string workflowName,
TInput input,
string? runId = null,
CancellationToken cancellationToken = default)
where TInput : notnull
=> this.StreamAsync(this.ResolveWorkflow(workflowName), input, runId, cancellationToken);

/// <inheritdoc/>
public ValueTask<IStreamingWorkflowRun> StreamAsync(
string workflowName,
string input,
string? runId = null,
CancellationToken cancellationToken = default)
=> this.StreamAsync<string>(workflowName, input, runId, cancellationToken);

/// <summary>
/// Resolves a registered workflow by name.
/// </summary>
/// <param name="workflowName">The name of the workflow to resolve.</param>
/// <returns>The registered <see cref="Workflow"/>.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="workflowName"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="workflowName"/> is empty.</exception>
/// <exception cref="WorkflowNotRegisteredException">Thrown when no workflow with the specified name has been registered.</exception>
private Workflow ResolveWorkflow(string workflowName)
{
ArgumentException.ThrowIfNullOrEmpty(workflowName);

if (!this._options.Workflows.Workflows.TryGetValue(workflowName, out Workflow? workflow))
{
throw new WorkflowNotRegisteredException(workflowName);
}

return workflow;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,18 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// Note: User-defined executor input/output types still use reflection-based serialization
/// since their types are not known at compile time.
/// </para>
/// <para>
/// Deserialization is case-insensitive because these payloads are not always written by this
/// library. When hosted in Azure Functions, the Durable Task worker serializes orchestration
/// output with the default <c>JsonDataConverter</c>, which applies no naming policy and therefore
/// writes PascalCase. Matching case-sensitively would silently bind every property to null.
/// </para>
/// </remarks>
[JsonSourceGenerationOptions(
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true)]
[JsonSerializable(typeof(DurableActivityInput))]
[JsonSerializable(typeof(DurableExecutorOutput))]
[JsonSerializable(typeof(TypedPayload))]
Expand Down
Loading
Loading