You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
.NET: [Bug]: HITL in Durable workflows the Executor TInput state is lost after approval is granted. #76
When a durable workflow pauses for Human-in-the-Loop (HITL) approval and execution resumes after approval is granted the executor does not retain its original TInput state. As a result, the input passed to the executor before the approval is lost or reset, preventing the executor from continuing with the same context.
The executor should resume with the original TInput instance so that workflow execution can continue seamlessly without requiring the input to be reconstructed again.
In the code sample,"isapproved" property value is maintained post HITL approval that comes from the value passed during the workflow invocation. Other property values like "inputnumber" and "id" are reset to empty string.
Code Sample
public sealed class _Response
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("inputnumber")]public string InputNumber { get; set; } = string.Empty;[JsonPropertyName("isapproved")]public Boolean IsApproved { get; set; }
}
internal sealed class SendNotificationExecutor() : Executor<_Response>("SendNotificationExecutor")
{
[YieldsOutput(typeof(string))]
public override async ValueTask HandleAsync(_Response response, IWorkflowContext context, CancellationToken cancellationToken = default)
{
var numbertype = await context.ReadStateAsync<DetectionResult>(response.Id, scopeName: NumbervalueConstants.NumberTypeScope);
await context.YieldOutputAsync($"The value {response.InputNumber} is forwarded to related department.");
}
}
PowerShell Invocation
$json = '{
"eventName": "NotificationApproval",
"response": { "isapproved": true}
}'
Invoke-RestMethod `
-Uri "http://localhost:7001/api/workflows/NumberDetector/respond/{runId}" `
-Method Post `
-ContentType "text/json" `
-Body $json
🤖 Automated triage reproduction notes(agent-authored — trust but verify)
Agent analysis
Repro: DurableExecutorDispatcher.ExecuteRequestPortAsync in dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs (line 129) returns only the external event response, discarding the original input parameter that was passed to the RequestPort. The original input IS stored in PendingEvents (line 117) but is never merged with the response before routing downstream. Minimal repro: create a workflow with Executor -> RequestPort -> Executor where the downstream executor expects properties from the original input; send a partial response via the respond API — the downstream executor's TInput will have default values for all properties not in the response.
The behavior you are seeing is by design and matches MAF core (in-process) behavior. It is not a durable workflow specific bug.
A RequestPort<TRequest, TResponse> forwards only the response to downstream executors. The original request sent into the port is never carried forward, in either the durable or the in-process engine. So when the approver replies with a partial body like {"Approved": true}, the downstream executor only gets a payload with those subset of properties.
Two supported workarounds:
Shared state . Before the port, persist the original request to shared workflow state with QueueStateUpdateAsync(key, request, scopeName). After the approval, read it back with ReadStateAsync(...) and merge it with the approver's outcome. In the durable engine, state under a named scope is shared across executors in the run, so the original fields survive even a partial response.
Echo the fields in the response body. Include the original fields in the /respond payload so they flow through as part of the response.
I have used the first option i.e. the shared state to persist the request. But as RequestPort resets all the properties, there's no way to access the values stored from the shared state afterward.
If you look at code that I posted earlier , I am trying to read from the shared state
internal sealed class SendNotificationExecutor() : Executor<_Response>("SendNotificationExecutor")
{
[YieldsOutput(typeof(string))]
public override async ValueTask HandleAsync(_Response response, IWorkflowContext context, CancellationToken cancellationToken = default)
{
var numbertype = await context.ReadStateAsync<DetectionResult>(response.Id, scopeName: NumbervalueConstants.NumberTypeScope);
await context.YieldOutputAsync($"The value {response.InputNumber} is forwarded to related department.");
}
}
However as the Id property in the response object loses its initial value after the Human-in-the-Loop (HITL) step it is not possible to read from the shared state which subsequently causes the executor to error out.
Thanks, that is a useful data point. Before I go further, I want to make sure I am comparing the same thing.
In both engines only the response is routed downstream; neither merges the original request. What changes the outcome is how the response is built. In a quick in-process repro I see both results:
Partial object {isapproved:true} : Id='' InputNumber='' IsApproved=True (fields lost)
Reusing the original request object : Id='ABC-123' InputNumber='42' IsApproved=True
So in-process also drops the fields if the response is a fresh partial object, and keeps them if it is built from the original request.
How are you sending the response in your in-process test? If you can share that snippet (the part that constructs and sends the response), I can confirm whether you are hitting the same path as the durable /respond endpoint, which always deserializes a fresh object from the posted JSON.
On the shared-state error: it is likely an invalid key. You read with response.Id, but after a partial response Id is empty, so the lookup uses an empty key and misses. If you confirm that is what you are seeing, the fix is to store and read under a stable key (a constant per run, or the run id) rather than a field that only exists on the original request. I will follow up with the full shared-state pattern and a possible hosting-side enhancement once we are aligned on the in-process question.
Thank you, Shyju, for looking into this once again.
I think it would be easier if I share a screencast with you to understand the issue I'm facing. Hopefully that will help us get on the same page. Please give me some time for this
Regarding the shared state, I'm storing the key-value pair in ExecutorA before the RequestPort is invoked:
In the same executor, I also make sure that the Id property in response object is set
response.Id=input.Id;
Then in ExecutorB, which executes immediately after the RequestPort, I try to read the shared state using the response.Id value that was set in ExecutorA:
However, after the RequestPort completes, response.Id is reset to empty instead of retaining the value assigned in ExecutorA. A result of which I'm unable to retrieve the state using the same key.
request.TryGetDataAs<_Response>(out var response);
response.approved = true;
request.CreateResponse(response);
TryGetDataAs returns the original in-memory object with all fields (Id, InputNumber) already populated. You set approved on that same object and send it back. All fields survive because you're reusing the original object.
This deserializes into a fresh_Response where Id and InputNumber are empty strings.
This is not a durable vs in-process difference. If you build a durable console app that responds the same way your in-process code does (extract original via GetInputAs, modify, send back), all fields will survive. The difference is purely in how the response is constructed: reusing the original object vs sending partial JSON over HTTP.
Why your shared-state workaround also failed
In your durable app, you store state keyed by input.Id upstream, but read it with response.Id downstream. After a partial HTTP response, response.Id is an empty string, so the lookup misses. Using a stable key (e.g., a constant or the workflow run ID) fixes this.
Workarounds
Send the full object in the HTTP response body (include id, inputnumber, and isapproved)
Use shared state with a stable key (not a field from the response object)
I tried both of your solutions and they both work as expected, thanks for that.
But I still think it will be more prudent if there is some mechanism through which the state is maintained across complete workflow lifecycle because sometimes its not possible to reconstruct and resend the complete object for HITL approval event as in some cases there are around 15+ properties that will have to be resend thru HTTP response.
IMHO , your second option of using a stable key is much more feasible as there would be no need to send over the entire object over HTTP response but there are some challenges with this approach.
The stable key approach would mean that I will have to send a static key as an argument to the HandleAsync method of the executor
For instance
publicoverrideasyncValueTaskHandleAsync(_Responseresponse,IWorkflowContextcontext,intkey,CancellationTokencancellationToken=default){// some operation to read the value of the key from the shared state}
I am not sure how can I pass the value to the key in the workflow.
This is a good example of why durable workflow support should be decomposed into independent guarantees rather than treated as one binary capability.
Here the workflow can pause and resume, and part of the approval state survives, but the original executor input does not survive intact.
So:
durable checkpoint ≠ complete state preservation ≠ semantically correct resume
We recently wrote up this distinction across several agent runtimes: https://safal207.github.io/RESONANCE/conformance-is-a-vector-not-a-verdict.ru.html�
For this bug, I think the useful invariant is:
executor receives TInput X
→
workflow suspends for HITL
→
approval is persisted
→
workflow resumes
→
executor must observe the same semantic TInput X
The approval response should update only the part of state it owns. It should not implicitly replace the pre-interruption executor input with a partial object.
A regression fixture could therefore record a digest of the original executor input before suspension and compare it after resume:
input_before = hash(canonical(TInput))
input_after = hash(canonical(resumed TInput excluding fields intentionally mutated by approval))
input_before == input_after
If the workflow can resume while silently resetting fields such as id or inputnumber, then the persistence layer has succeeded structurally but the resume contract has failed semantically.
That distinction is worth making explicit because workflow resumed successfully is otherwise a much stronger claim than the evidence supports.
Sorry for the delayed response. Glad the workarounds are working. Agreed that shared state is the better fit when the object has many properties, since it avoids having to echo everything back in the HTTP response.
The stable key doesn't need to be passed as a parameter to HandleAsync. You can use a simple constant that both executors share. For example:
// Shared constant - both executors reference thispublicstaticclassStateKeys{publicconststringOriginalRequest="OriginalRequest";}// Upstream executor: store before the RequestPortpublicoverrideasyncValueTask<_Response>HandleAsync(stringmessage,IWorkflowContextcontext,CancellationTokenct=default){varresponse=new_Response{Id=Guid.NewGuid().ToString(),InputNumber=message};awaitcontext.QueueStateUpdateAsync(StateKeys.OriginalRequest,response,scopeName:"MyWorkflow",ct);returnresponse;}// Downstream executor: read it back after approvalpublicoverrideasyncValueTaskHandleAsync(_Responseresponse,IWorkflowContextcontext,CancellationTokenct=default){varoriginal=awaitcontext.ReadStateAsync<_Response>(StateKeys.OriginalRequest,scopeName:"MyWorkflow",ct);// original has all the fields; response has the approval decision}
Both executors use the same constant key ("OriginalRequest") and the same scope name ("MyWorkflow"). No changes to the HandleAsync signature needed.
Aleksey Safonov (@safal207) Thanks for the input. The current behavior is that the response endpoint delivers exactly what the caller sends. Auto-merging partial responses onto the original request is an interesting enhancement idea, and we'd welcome a feature request for that.
Activity
github-actions commentedon Jun 28, 2026
🤖 Automated triage reproduction notes (agent-authored — trust but verify)
Agent analysis
Repro: DurableExecutorDispatcher.ExecuteRequestPortAsync in dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs (line 129) returns only the external event response, discarding the original input parameter that was passed to the RequestPort. The original input IS stored in PendingEvents (line 117) but is never merged with the response before routing downstream. Minimal repro: create a workflow with Executor -> RequestPort -> Executor where the downstream executor expects properties from the original input; send a partial response via the respond API — the downstream executor's TInput will have default values for all properties not in the response.
dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/RequestPortInputStateLossTests.cs1.8.0kshyju commentedon Jun 29, 2026
The behavior you are seeing is by design and matches MAF core (in-process) behavior. It is not a durable workflow specific bug.
A
RequestPort<TRequest, TResponse>forwards only the response to downstream executors. The original request sent into the port is never carried forward, in either the durable or the in-process engine. So when the approver replies with a partial body like{"Approved": true}, the downstream executor only gets a payload with those subset of properties.Two supported workarounds:
QueueStateUpdateAsync(key, request, scopeName). After the approval, read it back withReadStateAsync(...)and merge it with the approver's outcome. In the durable engine, state under a named scope is shared across executors in the run, so the original fields survive even a partial response./respondpayload so they flow through as part of the response.Hope this information helps.
Sachin-Nand commentedon Jun 29, 2026
Thank you for looking into this.
I have used the first option i.e. the shared state to persist the request. But as
RequestPortresets all the properties, there's no way to access the values stored from the shared state afterward.If you look at code that I posted earlier , I am trying to read from the shared state
where
Idis the key .However as the
Idproperty in the response object loses its initial value after the Human-in-the-Loop (HITL) step it is not possible to read from the shared state which subsequently causes the executor to error out.Sachin-Nand commentedon Jun 29, 2026
I did a quick check for InProcess workflow execution and I don't see this issue there.
The RequestPort does maintain the TInput state of the Executor in the InProcess workflow execution.
kshyju commentedon Jun 30, 2026
Thanks, that is a useful data point. Before I go further, I want to make sure I am comparing the same thing.
In both engines only the response is routed downstream; neither merges the original request. What changes the outcome is how the response is built. In a quick in-process repro I see both results:
So in-process also drops the fields if the response is a fresh partial object, and keeps them if it is built from the original request.
How are you sending the response in your in-process test? If you can share that snippet (the part that constructs and sends the response), I can confirm whether you are hitting the same path as the durable
/respondendpoint, which always deserializes a fresh object from the posted JSON.On the shared-state error: it is likely an invalid key. You read with
response.Id, but after a partial responseIdis empty, so the lookup uses an empty key and misses. If you confirm that is what you are seeing, the fix is to store and read under a stable key (a constant per run, or the run id) rather than a field that only exists on the original request. I will follow up with the full shared-state pattern and a possible hosting-side enhancement once we are aligned on the in-process question.Sachin-Nand commentedon Jun 30, 2026
Thank you, Shyju, for looking into this once again.
I think it would be easier if I share a screencast with you to understand the issue I'm facing. Hopefully that will help us get on the same page. Please give me some time for this
Regarding the shared state, I'm storing the key-value pair in
ExecutorAbefore theRequestPortis invoked:In the same executor, I also make sure that the
Idproperty inresponseobject is setThen in
ExecutorB, which executes immediately after theRequestPort, I try to read the shared state using theresponse.Idvalue that was set inExecutorA:However, after the
RequestPortcompletes,response.Idis reset to empty instead of retaining the value assigned inExecutorA. A result of which I'm unable to retrieve the state using the same key.Sachin-Nand commentedon Jun 30, 2026
Please find the screencast . At first I executed the non durable workflow and then executed a durable workflow with DTS running on Docker
https://1drv.ms/v/c/2b868dfe462cb2eb/IQBjuI0txlwaRp9Ckp2eJzb6Aafi8XdeqtcVbeJqkvxwyws?e=liuBZB
kshyju commentedon Jun 30, 2026
Sachin-Nand Would you mind sharing a minimal repro? A GH repository with your repro app will be great.
Sachin-Nand commentedon Jul 1, 2026
I have created the repo on GH
Durable Workflows >> https://github.com/Sachin-Nand/Azure_Durable_Workflow_HITL
Non Durable Workflows >> https://github.com/Sachin-Nand/Non_Durable_Workflow_HITL
Please ensure you have the following values entered in
appsettings.json"AppSettings": {
"Chat_DeploymentName": "",
"EndPoint": "",
"ApiKey": ""
}
kshyju commentedon Jul 2, 2026
Thanks for sharing the repro repos. They helped pinpoint exactly what's happening.
Why fields survive in your in-process app
In your non-durable Program.cs, HandleExternalRequest does:
TryGetDataAsreturns the original in-memory object with all fields (Id,InputNumber) already populated. You setapprovedon that same object and send it back. All fields survive because you're reusing the original object.Why fields are lost in your durable app
Your Azure Functions app sends the response via HTTP:
{"isapproved": true}This deserializes into a fresh
_ResponsewhereIdandInputNumberare empty strings.This is not a durable vs in-process difference. If you build a durable console app that responds the same way your in-process code does (extract original via
GetInputAs, modify, send back), all fields will survive. The difference is purely in how the response is constructed: reusing the original object vs sending partial JSON over HTTP.Why your shared-state workaround also failed
In your durable app, you store state keyed by
input.Idupstream, but read it withresponse.Iddownstream. After a partial HTTP response,response.Idis an empty string, so the lookup misses. Using a stable key (e.g., a constant or the workflow run ID) fixes this.Workarounds
id,inputnumber, andisapproved)Sachin-Nand commentedon Jul 6, 2026
Thank you kshyju for having a look into this. Please give me a day or two to look into your findings.
Sachin-Nand commentedon Jul 14, 2026
Hello kshyju ,
Sorry for the delayed response .
I tried both of your solutions and they both work as expected, thanks for that.
But I still think it will be more prudent if there is some mechanism through which the state is maintained across complete workflow lifecycle because sometimes its not possible to reconstruct and resend the complete object for HITL approval event as in some cases there are around 15+ properties that will have to be resend thru HTTP response.
IMHO , your second option of using a stable key is much more feasible as there would be no need to send over the entire object over HTTP response but there are some challenges with this approach.
The stable key approach would mean that I will have to send a static key as an argument to the
HandleAsyncmethod of the executorFor instance
I am not sure how can I pass the value to the key in the workflow.
safal207 commentedon Aug 23, 2026
This is a good example of why durable workflow support should be decomposed into independent guarantees rather than treated as one binary capability.
Here the workflow can pause and resume, and part of the approval state survives, but the original executor input does not survive intact.
So:
durable checkpoint ≠ complete state preservation ≠ semantically correct resume
We recently wrote up this distinction across several agent runtimes:
https://safal207.github.io/RESONANCE/conformance-is-a-vector-not-a-verdict.ru.html�
For this bug, I think the useful invariant is:
executor receives TInput X
→
workflow suspends for HITL
→
approval is persisted
→
workflow resumes
→
executor must observe the same semantic TInput X
The approval response should update only the part of state it owns. It should not implicitly replace the pre-interruption executor input with a partial object.
A regression fixture could therefore record a digest of the original executor input before suspension and compare it after resume:
input_before = hash(canonical(TInput))
input_after = hash(canonical(resumed TInput excluding fields intentionally mutated by approval))
input_before == input_after
If the workflow can resume while silently resetting fields such as id or inputnumber, then the persistence layer has succeeded structurally but the resume contract has failed semantically.
That distinction is worth making explicit because workflow resumed successfully is otherwise a much stronger claim than the evidence supports.
kshyju commentedon Aug 25, 2026
Sachin-Nand
Sorry for the delayed response. Glad the workarounds are working. Agreed that shared state is the better fit when the object has many properties, since it avoids having to echo everything back in the HTTP response.
The stable key doesn't need to be passed as a parameter to
HandleAsync. You can use a simple constant that both executors share. For example:Both executors use the same constant key (
"OriginalRequest") and the same scope name ("MyWorkflow"). No changes to theHandleAsyncsignature needed.kshyju commentedon Aug 25, 2026
Aleksey Safonov (@safal207) Thanks for the input. The current behavior is that the response endpoint delivers exactly what the caller sends. Auto-merging partial responses onto the original request is an interesting enhancement idea, and we'd welcome a feature request for that.