Skip to main content

Command Palette

Search for a command to run...

Human-In-The-Loop (HITL) in Microsoft Agent Framework Workflow without ToolApprovalRequestContent

Updated
6 min read
Human-In-The-Loop (HITL) in Microsoft Agent Framework Workflow without ToolApprovalRequestContent
S
From Synapse Analytics, Power BI, Spark, Microsoft Fabric,ASP.NET Core and recently Agentic AI on .NET I try to explore, learn and share all aspects of Microsoft Data Stack in this blog.

The general approach for Human-In-The-Loop (HITL) in Microsoft Agent Framework is through ToolApprovalRequestContent driven by AIFunction invocation wrapped around ApprovalRequiredAIFunction.

But for MAF workflows it is possible to achieve HITL without the need for wrapping your AIFunction with ApprovalRequiredAIFunction.

In this article we will be exploring the alternate approach

If you would like to know more about HITL throughApprovalRequiredAIFunction, you can refer to my article on the topic here .

To demonstrate how HITL can be leveraged without ApprovalRequiredAIFunction, we will use the same use case that I used in my article on MAF Workflows.

Use Case

This was the flow for the use case used in my previous article on HITL

Microsoft Agent Framework

So, we had an input number N that requires to be identified as a Prime or a non Prime number. If its a Prime number then compute its square root and send the value through Route A .

If its not a Prime number then send it through Route B without its square root.

Now , lets make some changes to the flow to accommodate HITL .

How about we add HITL approval before the value of √N is dispatched to Route A ?

So the flow would be as follows :

                          typeDetectionExecutor
                                   |
           -----------------------------------------------
           |                       |                     |
           v                       v                     v
     PrimeNumber               NotPrimeNumber          Default
           |                       |                     |
           v                       v                     v
PrimeNo_SquareRootExecutor   Executor_RouteB    Unsure_Executor 
           |
           |
           v
      HITL Approval
           |
           |
           v
  -----------------
  |               |
Rejected       Approved
  |               |
  |               |
  v               v
Log Rejection Executor_RouteA

To achieve this we will use a combination of RequestPortand RequestInfoEvent .

RequestPort & RequestInfoEvent >>

RequestPort can be defined as a channel through which executors can send and receive responses. But you might ask that it is already possible to do that through SendMessages or YieldsOutput .

The drawback with both SendMessages or YieldsOutput is that they cant emit responses as Events.

Recall that SendMessages sends messages to all the connected executors while YieldsOutput sends the output to the caller and both do not emit responses.

When an executor sends a message to RequestPort, it emits a RequestInfoEvent and then external inputs can listen to those RequestInfoEvent and then RequestInfoEvent sends it responses back to the workflow through RequestPort .

This is similar to ToolApprovalRequestContent but ToolApprovalRequestContent requires an AIFunction wrapped in ApprovalRequiredAIFunction and AIFunction to be invoked through an agent.

With RequestPort, AIFunction invocation is not required as the workflow can read the input and receive output through RequestPort which is then emitted in form of RequestInfoEvent.

To implement this we will have to go back and make some changes to workflow edges and the PrimeNo_SendDetails_RouteA_Executorthat routes the square root value to RouteA**.**

Refer to the flowchart above and code for PrimeNo_SendDetails_RouteA_Executor in my earlier article here.

Code

First, add a new property named IsApprovedto the _Response object of the workflow.

_Response >>

Earlier version :

public sealed class _Response
{
    [JsonPropertyName("decision")]
    public string Decision { get; set; } = string.Empty;

    [JsonPropertyName("reason")]
    public string Reason { get; set; } = string.Empty;

    [JsonPropertyName("id")]
    public string Id { get; set; } = string.Empty;

    [JsonPropertyName("squareroot")]
    public double squareroot { get; set; } = 0.00;
}

Changed version :

public sealed class _Response
{
    [JsonPropertyName("decision")]
    public string Decision { get; set; } = string.Empty;

    [JsonPropertyName("reason")]
    public string Reason { get; set; } = string.Empty;

    [JsonPropertyName("id")]
    public string Id { get; set; } = string.Empty;
   
    [JsonPropertyName("value")]
    public string value { get; set; } = String.Empty;
 
    [JsonPropertyName("squareroot")]
     public double squareroot { get; set; } = 0.00;
    
    [JsonPropertyName("approved")]
    public string IsApproved { get; set; } = string.Empty; 
}

RequestPort >>

 RequestPort humanApprovalPort = RequestPort.Create<_Response, _Response>("Approval");

RequestPort instance is of type T<TRequest,TResponse>.

In our case TRequest for RequestPort comes from PrimeNo_SquareRootExecutor whose output is of type _Response and the output from RequestPort is also of type _Response which in turn acts as an input to SendDetails_Executor_RouteA.

SendDetails_Executor_RouteA >>

Earlier version :

internal sealed class PrimeNo_SendDetails_RouteA_Executor() : Executor<_Response>("PrimeNo_SendDetails_RouteA_Executor")
{
    [YieldsOutput(typeof(string))]
    public override async ValueTask HandleAsync(_Response result, IWorkflowContext context, CancellationToken cancellationToken = default)
    {
        var number = await context.ReadStateAsync<InputNumber>(result.Id, scopeName: NumbervalueConstants.NumbervalueScope);
        await context.YieldOutputAsync($"Details for {number.Value} sent to Route A: {result.squareroot}", cancellationToken);

    }
}

Changed version :

 internal sealed class PrimeNo_SendDetails_RouteA_Executor() : Executor<_Response>("PrimeNo_SendDetails_RouteA_Executor")
 {
     [YieldsOutput(typeof(string))]

     public override async ValueTask HandleAsync(_Response result, IWorkflowContext context, CancellationToken cancellationToken = default)
     {
         var number = await context.ReadStateAsync<InputNumber>(result.Id, scopeName: NumbervalueConstants.NumbervalueScope);

         if (result.IsApproved == "Yes")
         {
             await context.YieldOutputAsync($"Request is approved !!! Details for {number.Value} sent to Route A with square root value of: {result.squareroot}", cancellationToken);
         }
         else
         {
             await context.YieldOutputAsync($"Request is rejected !!! Details of rejection for {number.Value} will be logged ", cancellationToken);
         }
         
     }
 }

The change above, is that in the changed version , we are reading the IsApproved property value which was set through RequestPort and based on the output from approval/rejection , a decision is made to route the square root value or log the rejection.

But, how to set the value for IsApproved property ?

This is done through ExternalResponse process where the executor pauses its execution awaiting an input from external system. But then you might wonder how to invoke the ExternalResponse process.

This is done by adding the RequestPort as part of the workflow Edge.

Recall that earlier we declared RequestPort with name humanApprovalPort.

Edges >>

Earlier version :

.AddEdge(PrimeNo_SquareRootExecutor,SendDetails_Executor_RouteA)
.WithOutputFrom(SendDetails_Executor_RouteA, SendDetails_Executor_RouteB, SendEmail_Executor_Unsure);

Changed version :

.AddEdge(PrimeNo_SquareRootExecutor, humanApprovalPort)
.AddEdge(humanApprovalPort, SendDetails_Executor_RouteA)
.WithOutputFrom(SendDetails_Executor_RouteA, SendDetails_Executor_RouteB, SendEmail_Executor_Unsure);

In the workflow event we then invoke ExternalResponse that returns a response object which is then sent to the Executor.

WorkflowEvent>>

Earlier version :

var workflow = builder.Build();

string input = "Your Input Number/Letter";

await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, new ChatMessage(ChatRole.User, input));

  await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
  await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
  {
    if (evt is WorkflowOutputEvent outputEvent)
    {
     Console.WriteLine($"{outputEvent}");
    }
  }

Changed version :

var workflow = builder.Build();

string input = "Your Input Number/Letter";

await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, new ChatMessage(ChatRole.User, input));

  await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
  await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
  {
    if (evt is WorkflowOutputEvent outputEvent)
    {
     Console.WriteLine($"{outputEvent}");
    }    
    
    if (evt is RequestInfoEvent requestInputEvt)
    {
     ExternalResponse response =  HandleExternalRequest(requestInputEvt.Request);
   await run.SendResponseAsync(response);
   }
 
  }

HandleExternalRequest >>

 private static ExternalResponse HandleExternalRequest(ExternalRequest request)
 {
     if (request.TryGetDataAs<_Response>(out var response))
     {

         Console.WriteLine($"Would you like to approve/reject ? please reply Y to approve and N to reject");

         response.IsApproved = Convert.ToString(Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) == true ? "Yes" : "No");

         return request.CreateResponse(response);
     }

     throw new NotSupportedException($"Request {request.PortInfo.RequestType} is not supported");
 }

As the output from PrimeNo_SquareRootExecutor is of type _Response, the _Response object becomes a part of ExternalRequest which in turn exposes the IsApproved property through the out variable response and post human interaction , the modified request is sent back as a response.

Execution >>

Conclusion

Through this article I tried to explore a more unique approach where HITL does not necessarily had to be dependent on AIFunction invocation through an AIAgent.

Instead, we saw how human approval can be introduced at different stages of an workflow through simple call mechanism through RequestPort and the responses can be handled through ExternalResponse object.

So ahead and give a try to this approach.

Thanks for reading !!!