Skip to content

[Bug] UseBraintrustTracing leaks Microsoft Agent Framework local history sentinel with RequirePerServiceCallChatHistoryPersistence #69

Description

@ips-jm

When using Braintrust.Sdk.AgentFramework with Microsoft Agent Framework and RequirePerServiceCallChatHistoryPersistence = true, UseBraintrustTracing(...) appears to create/configure its own FunctionInvokingChatClient layer instead of acting only as passive tracing middleware around the agent framework's existing function invocation pipeline.

That additional client layer seems to execute part of the tool-call loop outside the position where MAF expects its PerServiceCallChatHistoryPersistingChatClient to sit. As a result, MAF's internal local-history sentinel _agent_local_chat_history can reach the leaf IChatClient during streaming tool-call runs. With OpenAI Responses this sentinel is then interpreted as a conversation / previous response id, causing the request to fail.

Using only UseBraintrustLLMTracing(...) does not reproduce the issue.

Environment

  • .NET: 9.0
  • Microsoft.Agents.AI: 1.10.0
  • Braintrust.Sdk.AgentFramework: 0.2.8
  • Streaming responses
  • Local MAF history provider
  • RequirePerServiceCallChatHistoryPersistence = true
  • Tool-calling agent

Expected behavior

_agent_local_chat_history should remain internal to Microsoft Agent Framework.

The leaf IChatClient should never receive:

ChatOptions.ConversationId = "_agent_local_chat_history"

MAF's PerServiceCallChatHistoryPersistingChatClient is expected to strip this sentinel before forwarding calls to the underlying model client.

Actual behavior

When UseBraintrustTracing(...) is applied before building the MAF agent, the second streaming model call receives:

ChatOptions.ConversationId = "_agent_local_chat_history"

With OpenAI Responses this becomes invalid provider input, producing an error similar to:

Invalid 'previous_response_id': '_agent_local_chat_history'. Expected an ID that begins with 'resp'

Minimal repro

using System.Diagnostics;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Braintrust.Sdk.AgentFramework;

var activitySource = new ActivitySource("repro");

var leafClient = new ThrowIfSentinelChatClient();

var chatClient = leafClient
    .AsBuilder()
    .UseBraintrustTracing(activitySource)
    .Build();

// This variant does NOT reproduce:
// var chatClient = leafClient
//     .AsBuilder()
//     .UseBraintrustLLMTracing(activitySource)
//     .Build();

var agent = chatClient.AsBuilder().BuildAIAgent(new ChatClientAgentOptions
{
    Name = "SentinelRepro",
    ChatHistoryProvider = new InMemoryChatHistoryProvider(),
#pragma warning disable MAAI001
    RequirePerServiceCallChatHistoryPersistence = true,
#pragma warning restore MAAI001
    ChatOptions = new ChatOptions
    {
        Tools = [AIFunctionFactory.Create(() => "tool result", "test_tool")],
        AllowMultipleToolCalls = true
    }
});

var session = await agent.CreateSessionAsync();

await foreach (var update in agent.RunStreamingAsync("use the tool", session))
{
    Console.Write(update);
}

Console.WriteLine();

sealed class ThrowIfSentinelChatClient : IChatClient
{
    private int _callCount;

    public object? GetService(Type serviceType, object? serviceKey = null) => null;

    public Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        CancellationToken cancellationToken = default)
    {
        throw new NotSupportedException("Use streaming for this repro.");
    }

    public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        _callCount++;

        Console.WriteLine($"Leaf streaming call {_callCount}: ConversationId={options?.ConversationId ?? "<null>"}");

        if (options?.ConversationId == "_agent_local_chat_history")
        {
            throw new InvalidOperationException(
                "BUG: Leaf IChatClient received MAF local history sentinel as ConversationId: _agent_local_chat_history");
        }

        if (_callCount == 1)
        {
            yield return new ChatResponseUpdate
            {
                Role = ChatRole.Assistant,
                Contents =
                [
                    new FunctionCallContent("call_1", "test_tool")
                ]
            };
        }
        else
        {
            yield return new ChatResponseUpdate
            {
                Role = ChatRole.Assistant,
                Contents =
                [
                    new TextContent("done")
                ]
            };
        }

        await Task.CompletedTask;
    }

    public void Dispose()
    {
    }
}

Observed output

Without Braintrust tracing:

Leaf streaming call 1: ConversationId=<null>
Leaf streaming call 2: ConversationId=<null>
done
No sentinel reached the leaf client.

With UseBraintrustTracing(...):

Leaf streaming call 1: ConversationId=<null>
Leaf streaming call 2: ConversationId=_agent_local_chat_history
System.InvalidOperationException: BUG: Leaf IChatClient received MAF local history sentinel as ConversationId: _agent_local_chat_history

With UseBraintrustLLMTracing(...) only:

Leaf streaming call 1: ConversationId=<null>
Leaf streaming call 2: ConversationId=<null>
done
No sentinel reached the leaf client.

Suspected cause

UseBraintrustTracing(...) calls both LLM tracing and function tracing.

The function tracing path appears to call UseFunctionInvocation(...). This means Braintrust does not only attach tracing middleware around the existing MAF function invocation pipeline; it creates/configures another FunctionInvokingChatClient layer inside the IChatClient that is later passed to BuildAIAgent(...).

MAF's RequirePerServiceCallChatHistoryPersistence relies on PerServiceCallChatHistoryPersistingChatClient being positioned between the relevant FunctionInvokingChatClient loop and the leaf IChatClient, because that decorator strips _agent_local_chat_history before forwarding the request.

When Braintrust adds its own function invocation client before the agent is built, MAF can no longer fully control the function-invocation/history-persistence ordering. The MAF sentinel can then bypass the stripping decorator and reach the leaf model client.

In other words, the issue is not Braintrust tracing in general. UseBraintrustLLMTracing(...) works in the same minimal repro. The issue appears to be caused by Braintrust's function tracing implementation creating/configuring a function-invocation client instead of observing the function calls produced by MAF's own agent pipeline.

Request

Could Braintrust.Sdk.AgentFramework avoid creating/configuring a separate FunctionInvokingChatClient in this scenario, and instead instrument MAF tool/function calls without changing the function-invocation pipeline?

Alternatively, if the extra FunctionInvokingChatClient layer is required, could the SDK ensure it is composed in a way that keeps MAF's PerServiceCallChatHistoryPersistingChatClient between every function-invocation loop and the leaf model client?

At minimum, it would be helpful if the docs mention that UseBraintrustTracing(...) is currently incompatible with MAF local per-service-call history persistence.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions