Skip to content

.NET: Foundry-hosted workflows accept incomplete reply batches #9530

Open

Description

@herohua

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.py

The 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.

Activity

  1. added
    .NETUsage: [Issues, PRs], Target: .Net
    pythonUsage: [Issues, PRs], Target: Python
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Oct 19, 2026
  2. changed the title [-].NET: Foundry-hosted workflows accept partial reply batches unlike Python[/-] [+]Python: .NET: Foundry-hosted workflows accept partial reply batches unlike Python[/+] on Oct 19, 2026
  3. changed the title [-]Python: .NET: Foundry-hosted workflows accept partial reply batches unlike Python[/-] [+].NET: Foundry-hosted workflows accept incomplete reply batches[/+] on Oct 19, 2026
  4. MoustAhmed commented on Oct 19, 2026

    @MoustAhmed

    Hi I'd like to take this one.

    Cause: WorkflowSession.SendMessagesWithResponseConversionAsync matches each reply against _pendingRequests individually 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 on WorkflowSession instead if you'd prefer):

    1. Expose pending request IDs on WorkflowSessionCheckpointRecovery.
    2. In AgentFrameworkResponseHandler, validate the reply batch before EmitCreated(), throwing a 400 ResponsesApiException like the identity-mismatch check for missing, unknown or duplicate replies, or replies mixed with new input. Validating later (where input is loaded today) could only EmitFailed inside a 200 stream. Nothing is consumed, so the client can retry, and recovery (IsRecovery && sessionRestoredFromStore) is skipped.
    3. Add tests mirroring Python: host native workflows over Foundry Responses #8947: partial batch rejected with both requests still pending, full batch accepted.

    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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    .NETUsage: [Issues, PRs], Target: .NetpythonUsage: [Issues, PRs], Target: PythontriageUsage: [Issues], Target: All issues that still need to be triaged

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions