If you keep only the last few chat messages, you might end up with a tool result that no longer has the assistant message that requested it. The trimmed history fits your message limit, but your next model request contains an invalid tool sequence.

A single assistant message can request several tools. Keep that message and all its results together, or remove the group. The assistant’s follow-up answer can remain if your retention policy allows it.

user:      Check order 42 and its shipment.
assistant: GetOrder [order-1], GetShipment [shipment-1]
tool:      order result [order-1]
tool:      shipment result [shipment-1]
assistant: Your order has shipped.
user:      When should it arrive?

TakeLast(4) keeps both results but drops their calls. A reducer that respects a four-message limit must drop the results too, leaving the last two messages. With a limit of five, the complete group fits.

Test the cut

In an existing xUnit test class, this example uses Microsoft.Extensions.AI 10.10.0 and a custom policy: keep a suffix of recent conversation messages and discard any group split by the cut. Keep application-owned system instructions outside this list and prepend them after trimming.

[Fact]
public void Trimming_keeps_complete_tool_groups()
{
    ChatMessage[] history =
    [
        new(ChatRole.User, "Check order 42 and its shipment."),
        new(ChatRole.Assistant, [new FunctionCallContent("order-1", "GetOrder"),
            new FunctionCallContent("shipment-1", "GetShipment")]),
        new(ChatRole.Tool, [new FunctionResultContent("order-1", "Paid")]),
        new(ChatRole.Tool, [new FunctionResultContent("shipment-1", "Shipped")]),
        new(ChatRole.Assistant, "Your order has shipped."),
        new(ChatRole.User, "When should it arrive?")
    ];
    Assert.False(ValidPairs(history.TakeLast(4)));
    foreach (int limit in new[] { 3, 4 })
    {
        var reduced = Trim(history, limit);
        Assert.True(ValidPairs(reduced));
        Assert.Equal(2, reduced.Length);
    }
    var retained = Trim(history, 5);
    Assert.True(ValidPairs(retained));
    Assert.Equal(5, retained.Length);
    var contents = retained.SelectMany(m => m.Contents).ToArray();
    Assert.Equal(new[] { ("order-1", "GetOrder"), ("shipment-1", "GetShipment") },
        contents.OfType<FunctionCallContent>().Select(c => (c.CallId, c.Name)));
    Assert.Equal(new[] { ("order-1", "Paid"), ("shipment-1", "Shipped") },
        contents.OfType<FunctionResultContent>()
            .Select(r => (r.CallId, Assert.IsType<string>(r.Result))));
}

static ChatMessage[] Trim(ChatMessage[] history, int limit)
{
    ArgumentOutOfRangeException.ThrowIfNegative(limit);
    if (!ValidPairs(history)) throw new InvalidOperationException("Invalid history.");
    int start = Math.Max(0, history.Length - limit);
    while (start < history.Length && history[start].Role == ChatRole.Tool)
        start++;
    return history[start..];
}

static bool ValidPairs(IEnumerable<ChatMessage> history)
{
    var pending = new HashSet<string>(StringComparer.Ordinal);
    foreach (var message in history)
    {
        if (message.Role != ChatRole.Tool && pending.Count > 0) return false;
        foreach (var content in message.Contents)
        {
            if (content is FunctionCallContent call &&
                (message.Role != ChatRole.Assistant || !pending.Add(call.CallId)))
                return false;
            if (content is FunctionResultContent result &&
                (message.Role != ChatRole.Tool || !pending.Remove(result.CallId)))
                return false;
        }
    }
    return pending.Count == 0;
}

ValidPairs walks messages in order and matches results by CallId. It rejects duplicate calls, unmatched results, and a continuation before all results arrive. The three-message limit tests a cut between the results. Literal assertions check retained IDs, names, and payloads without relying on shared message objects.

This limit counts application ChatMessage objects, not tokens. Two results can share one .NET tool message. The OpenAI adapter emits separate Chat Completions tool messages for them.

The positive test checks our custom retention policy. The built-in MessageCountingChatReducer excludes messages containing function calls or results. A structurally valid sequence can still lose useful context. See Controlling Token Growth with Chat Reducers for reducer setup and tradeoffs.

When to use these tests

Use this pattern when trimming completed function-tool exchanges. The helper checks pair structure, not every provider constraint.

Do not use it during pending execution or approval: outstanding calls can be legitimate then. Provider-managed history and other API formats have their own continuation rules. Keep a separate token budget, including system instructions, and check that summaries preserve the facts your feature needs.

Sources