Repository navigation
.NET: Foundry-hosted workflows accept incomplete reply batches #9530
Description
Problem
.NET Foundry-hosted workflows accept incomplete reply batches, whereas the Python native Responses workflow host rejects them (test added in #8947).
Reproduced through a real local server started with azd, including Azure SDK request validation, history lookup, orchestration, and persistence:
| Client submits | HTTP / response status | Workflow execution |
|---|---|---|
| Only A, while A and B are pending | 200 / completed | Processes A immediately |
| B in the next request | 200 / completed | Processes B |
| A and B together in a fresh chain | 200 / completed | Processes both |
Please align hosted workflow behavior with Python: reject incomplete batches before execution or consumption, preserving pending requests for a full-batch retry. If partial continuation is intentional, please document and test that contract instead.
Reproduce
At main@f7a5fcbb8657b8d4f96e5c608b33679c401fa29b, save the files below under dotnet/.tmp/hosted-partial-repro/.
$env:ASPNETCORE_URLS="http://127.0.0.1:18088"
azd ai agent run repro --no-client --port 18088 --no-prompt
# In another terminal, from the same folder:
python reproduce.pyThe full-batch control succeeds; the partial-batch assertion fails:
Expected incomplete batch rejection before execution; observed
{"http": 200, "status": "completed", "texts": ["Received: call-A"]}
No Azure resources or LLM calls are needed. The client uses workflow-facing IDs ending in :call-A and :call-B, rather than inner-agent raw call IDs.
Complete reproduction files
HostedPartialRepro.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFrameworks></TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MAAI001;MAAI002</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj" />
</ItemGroup>
</Project>
Program.cs
// Copyright (c) Microsoft. All rights reserved.
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
var innerAgent = new TwoFunctionCallsAgent();
var workflow = new WorkflowBuilder(innerAgent.BindAsExecutor(
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = false })).Build();
var agent = workflow.AsAIAgent(name: "hosted-partial-repro", includeExceptionDetails: true);
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent, new InMemoryAgentSessionStore());
var app = builder.Build();
app.MapFoundryResponses();
app.Run();
internal sealed class TwoFunctionCallsAgent : AIAgent
{
protected override string IdCore => "lookup-agent";
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages, AgentSession? session, AgentRunOptions? options,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var results = messages.SelectMany(message => message.Contents).OfType<FunctionResultContent>().ToList();
if (results.Count == 0)
{
yield return new AgentResponseUpdate(ChatRole.Assistant,
[new FunctionCallContent("call-A", "lookup"), new FunctionCallContent("call-B", "lookup")]);
}
else
{
var ids = string.Join(", ", results.Select(result => result.CallId));
Console.WriteLine($"REPRO: inner agent received {ids}");
yield return new AgentResponseUpdate(ChatRole.Assistant, $"Received: {ids}");
}
await Task.CompletedTask.ConfigureAwait(false);
}
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages, AgentSession? session, AgentRunOptions? options,
CancellationToken cancellationToken = default) =>
this.RunStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
new(new Session());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
AgentSession session, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
new(JsonSerializer.SerializeToElement(new { }));
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
new(new Session());
private sealed class Session : AgentSession;
}azure.yaml
name: hosted-partial-repro
services:
repro:
project: .
host: azure.ai.agent
language: csharp
startupCommand: dotnet run --no-launch-profile --project HostedPartialRepro.csproj
kind: hosted
name: hosted-partial-repro
protocols:
- protocol: responses
version: 2.0.0
reproduce.py
import json
from pathlib import Path
from urllib.request import Request, urlopen
def post(body):
request = Request(
'http://127.0.0.1:18088/responses',
data=json.dumps(body).encode(),
headers={
'Content-Type': 'application/json',
'x-agent-user-id': 'repro-user',
'x-agent-foundry-call-id': 'repro-call',
},
)
with urlopen(request, timeout=30) as response:
return response.status, json.load(response)
def calls(response):
return [item for item in response['output']
if item['type'] == 'function_call' and ':call-' in item['call_id']]
def texts(response):
return [part['text'] for item in response['output'] if item['type'] == 'message'
for part in item['content'] if part['type'] == 'output_text']
def replies(previous, pending):
return {'previous_response_id': previous['id'], 'input': [
{'type': 'function_call_output', 'call_id': item['call_id'], 'output': 'result'}
for item in pending
]}
_, first = post({'input': 'Start'})
pending = calls(first)
assert len(pending) == 2, pending
partial_request = replies(first, pending[:1])
partial_http, partial = post(partial_request)
remaining_http, remaining = post(replies(partial, pending[1:]))
_, control_start = post({'input': 'Start'})
full_http, full = post(replies(control_start, calls(control_start)))
observed = {name: {'http': code, 'status': body['status'], 'texts': texts(body)}
for name, code, body in [('partial', partial_http, partial),
('remaining', remaining_http, remaining),
('full', full_http, full)]}
print(json.dumps(observed, indent=2))
Path('observed.json').write_text(json.dumps(observed, indent=2), encoding='utf-8')
assert full_http == 200 and full['status'] == 'completed', full
assert texts(full) == ['Received: call-A', 'Received: call-B'], full
assert partial['status'] == 'failed', (
'Expected incomplete batch rejection before execution; observed ' + json.dumps(observed['partial'])
)Environment
Windows; .NET SDK 10.0.402, target net10.0; framework source at the commit above; Azure.AI.AgentServer.Responses 1.0.0-beta.8; azd 1.36.0, agent extension 1.0.0-beta.20. Regression status unknown.
Local azd/HTTP reproduction only; no cloud deployment. Production code is unchanged and no implementation PR is proposed. AI-assisted investigation and test execution.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsNo status
Activity
[-].NET: Foundry-hosted workflows accept partial reply batches unlike Python[/-][+]Python: .NET: Foundry-hosted workflows accept partial reply batches unlike Python[/+][-]Python: .NET: Foundry-hosted workflows accept partial reply batches unlike Python[/-][+].NET: Foundry-hosted workflows accept incomplete reply batches[/+]MoustAhmed commentedon Oct 19, 2026
Hi I'd like to take this one.
Cause:
WorkflowSession.SendMessagesWithResponseConversionAsyncmatches each reply against_pendingRequestsindividually and never checks the batch is complete, so a reply to A alone runs A and leaves B pending. Unlike Python, unknown call IDs also fall through as regular content and duplicates are silently skipped.Proposed fix, done in the Foundry host so
WorkflowSession's behavior outside hosting is unchanged (happy to make it an opt-in strict mode onWorkflowSessioninstead if you'd prefer):WorkflowSessionCheckpointRecovery.AgentFrameworkResponseHandler, validate the reply batch beforeEmitCreated(), throwing a 400ResponsesApiExceptionlike the identity-mismatch check for missing, unknown or duplicate replies, or replies mixed with new input. Validating later (where input is loaded today) could onlyEmitFailedinside a 200 stream. Nothing is consumed, so the client can retry, and recovery (IsRecovery && sessionRestoredFromStore) is skipped.Question: I'd go with rejecting incomplete batches, as suggested in the issue. Can a maintainer confirm partial continuation isn't intended?
Happy to open a PR once agreed. Thanks!