microsoft / microsoft/agent-framework
.NET: [Feature]: A filterToolCallMessages: bool option on AsAIAgent()
@peibekwe is already working on this.
Since Jul 7, 2026.
- Dominant language
- Python
- Stars
- 13.6k
- Forks
- 2.3k
- Avg merge
- 2d 45m
- Merged PRs (30d)
- 358
Description
### 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
```markdown
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 RunCoreAsync(
IEnumerable messages,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default) =>
base.RunCoreAsync(WithoutToolCallMessages(messages), session, options, cancellationToken);
protected override IAsyncEnumerable RunCoreStreamingAsync(
IEnumerable messages,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default) =>
base.RunCoreStreamingAsync(WithoutToolCallMessages(messages), session, options, cancellationToken);
private static List WithoutToolCallMessages(IEnumerable messages) =>
[.. messages.Where(m => !m.Contents.Any(c => c is FunctionCallContent or FunctionResultContent))];
}
```
### Language/SDK
.NET
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.