Skip to content

.NET: [Feature]: A filterToolCallMessages: bool option on AsAIAgent() #6874

Description

@dcostea

Description

When a tool-calling orchestration (e.g. BuildConcurrent) is wrapped as an agent via AsAIAgent() and composed into an outer sequential workflow via BuildSequential, the WorkflowHostAgent that backs it collects every internal AgentResponseUpdate — including the FunctionCallContent / FunctionResultContent pairs produced by the inner agents — and includes them verbatim in its own response.
The next agent in the sequential chain then receives a conversation that contains tool-call IDs it never issued. The provider rejects it with:

HTTP 400 – An assistant message with 'tool_calls' must be followed by tool messages
responding to each 'tool_call_id'. The following tool_call_ids did not have response
messages: call_xxx, call_yyy, …

This breaks the documented workflows-as-agents composition pattern any time the inner orchestration's agents use tools.

Expected behavior
When AsAIAgent() is used as a composition primitive — feeding its output into an outer workflow — the response it surfaces to the outer workflow should not expose FunctionCallContent / FunctionResultContent messages from the inner agents. Those pairs are an implementation detail of the inner orchestration's execution and are meaningless (and actively harmful) to any downstream agent that never issued the corresponding tool calls.
A reasonable fix would be one of:
• A filterToolCallMessages: bool parameter on AsAIAgent() / WorkflowHostAgent (opt-in for composition scenarios)
• A future BuildSequential boundary option that applies the stripping automatically (??)

Alternative Why it does not work
BuildSequential(chainOnlyAgentResponses: true) Controls whether the user-side conversation history is forwarded; it has no effect on what the wrapped agent includes in its own response.
AsAIAgent(includeWorkflowOutputsInResponse: true) Adds the aggregated output to the response but does not suppress the leaked tool-call messages.
SubworkflowBinding (low-level WorkflowBuilder) Correctly contains the inner execution, but it bypasses the high-level builder helpers and breaks the workflows-as-agents abstraction promoted by the documentation.
Custom DelegatingAIAgent wrapper Works as a workaround by stripping FunctionCallContent and FunctionResultContent in RunCoreAsync and RunCoreStreamingAsync, but it should not be required as user-side boilerplate at every composition boundary.

Code Sample

This doesn't work:

Workflow safetyWorkflow = AgentWorkflowBuilder.BuildConcurrent([maintenanceAgent, environmentAgent], SafetyAggregator.AggregateClearances);

AIAgent safetyStage = safetyWorkflow.AsAIAgent("SafetyStage", includeWorkflowOutputsInResponse: true);

Workflow workflow = AgentWorkflowBuilder.BuildSequential(safetyStage, motorsAgent);


This works (workaround applying tool calling stripping):

Workflow safetyWorkflow = AgentWorkflowBuilder.BuildConcurrent([maintenanceAgent, environmentAgent], SafetyAggregator.AggregateClearances);

AIAgent safetyStage = safetyWorkflow.AsAIAgent("SafetyStage", includeWorkflowOutputsInResponse: true);

AIAgent motorsStage = new ToolCallFilteringAgent(motorsAgent);

Workflow workflow = AgentWorkflowBuilder.BuildSequential(safetyStage, motorsStage);


ToolCallFilteringAgent (for stripping down the tool callings from the response)

public sealed class ToolCallFilteringAgent(AIAgent innerAgent) : DelegatingAIAgent(innerAgent)
{
  protected override Task<AgentResponse> RunCoreAsync(
    IEnumerable<ChatMessage> messages,
    AgentSession? session = null,
    AgentRunOptions? options = null,
    CancellationToken cancellationToken = default) =>
    base.RunCoreAsync(WithoutToolCallMessages(messages), session, options, cancellationToken);

  protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
    IEnumerable<ChatMessage> messages,
    AgentSession? session = null,
    AgentRunOptions? options = null,
    CancellationToken cancellationToken = default) =>
    base.RunCoreStreamingAsync(WithoutToolCallMessages(messages), session, options, cancellationToken);

  private static List<ChatMessage> WithoutToolCallMessages(IEnumerable<ChatMessage> messages) =>
    [.. messages.Where(m => !m.Contents.Any(c => c is FunctionCallContent or FunctionResultContent))];
}

Language/SDK

.NET

Metadata

Metadata

Assignees

No one assigned

    Labels

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

    Fields

    No fields configured for Feature.

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions