Skip to content

.NET: [Bug]: HarnessAgent forces persisted ChatHistoryProvider state, breaking AgentWorkflowBuilder Handoff orchestration #7384

Description

@MirkoMattioliSacmi

Description

HarnessAgent.BuildInnerAgent unconditionally builds its inner ChatClientAgent
with ChatHistoryProvider and RequirePerServiceCallChatHistoryPersistence = true
— no HarnessAgentOptions flag disables either.

This conflicts with Handoff orchestration
(Microsoft.Agents.AI.Workflows.Specialized.HandoffAgentExecutor), which owns
the full multi-party conversation itself and re-invokes each participant as a
stateless function, resending the relevant message slice every turn. The
official Handoff sample (dotnet/samples/03-workflows/Orchestration/Handoff)
builds participants with chatClient.AsAIAgent(...) — stateless, not
HarnessAgent.

Using a HarnessAgent as a Handoff participant instead breaks the handoff
tool call — a declaration-only AIFunction
(AIFunctionFactory.CreateDeclaration) intentionally left un-invoked so
HandoffAgentExecutor can intercept it. Observed symptoms in our app:

  • A turn producing no response at all (silent hang).
  • The handoff tool's arguments emitted as literal assistant text instead
    of a FunctionCallContent, e.g. {"reasonForHandoff":"..."} printed as
    chat output rather than executed as a tool call.

Likely cause: the executor reuses one AgentSession per participant across
turns, resending the full conversation slice itself each time; HarnessAgent's
forced ChatHistoryProvider tracks its own copy of the conversation against
that same session. The two diverge, and the model ends up with a
confused/duplicated context instead of emitting a clean tool call.

Caveat: this is backed by the observed app-level symptoms plus a source
read of HarnessAgent.cs/HandoffAgentExecutor.cs, not an isolated minimal
console repro. No official sample combines HarnessAgent with
AgentWorkflowBuilder, and we found no existing issue covering this
combination.

Code Sample

// Works today — the official Handoff sample's pattern.
AIAgent workingParticipant = chatClient.AsAIAgent(instructions: "...", name: "triage");

// Breaks — HarnessAgent with everything non-essential disabled.
AIAgent brokenParticipant = chatClient.AsHarnessAgent(new HarnessAgentOptions
{
    Name = "triage",
    HarnessInstructions = string.Empty,
    ChatOptions = new ChatOptions { Instructions = "..." },
    DisableToolAutoApproval = true,
    DisableWebSearch = true,
    DisableFileMemory = true,
    DisableTodoProvider = true,
    DisableAgentModeProvider = true,
    DisableAgentSkillsProvider = true,
    DisableOpenTelemetry = true,
    // No flag disables ChatHistoryProvider / RequirePerServiceCallChatHistoryPersistence.
});

#pragma warning disable MAAIW001
Workflow workflow = AgentWorkflowBuilder
    .CreateHandoffBuilderWith(brokenParticipant) // swap in workingParticipant to compare
    .WithHandoff(brokenParticipant, someExpertAgent, "reason for handoff")
    .Build();
#pragma warning restore MAAIW001

Error Messages / Stack Traces

No exception — the failure is behavioral:


Turn 1 (expected a silent handoff): no response streamed at all.

Turn 2 (expected a silent handoff to a different expert):
  Assistant: {"reasonForHandoff":"order status question, belongs to the Orders domain"}


The second line is the handoff tool's JSON arguments as plain text, not a
`FunctionCallContent` named `handoff_N`.

Package Versions

Microsoft.Agents.AI: 1.15.0, Microsoft.Agents.AI.Harness: 1.15.0, Microsoft.Agents.AI.Workflows: 1.15.0, Microsoft.Agents.AI.Hosting.AGUI.AspNetCore: 1.15.0-preview.260722.1

.NET Version

.NET 10.0

Additional Context

Suggested Fix

  • Add a HarnessAgentOptions flag to opt out of ChatHistoryProvider/
    RequirePerServiceCallChatHistoryPersistence, so HarnessAgent can be used
    as a stateless Workflow participant, or
  • document that HarnessAgent isn't meant to be used as a Workflow/Handoff
    participant.

Happy to help narrow this down further if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

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

    Type

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions