A tool result becomes model input on the next turn. A search tool that returns hundreds of rows can consume more context than the user’s question and system instructions combined.

Cap what the model sees before the tool returns. Do not rely on a chat reducer to repair an oversized tool result after it has entered the tool loop.

Start at the query boundary. Project only the fields the model needs, limit the number of records, and bound free-form text such as descriptions or document snippets. Return a small summary rather than a complete object when the model does not need every field.

For a valid partial page, include HasMore and an opaque continuation token. The model can then request the next page or make a narrower call when it needs more detail.

The query limits should do most of the work. Keep a token check as the final guard, using the same JSON options that AIFunctionFactory uses for the return value:

using System.Text.Json;
using Microsoft.Extensions.AI;
using Microsoft.ML.Tokenizers;

public sealed record TicketSummary(
    string Id,
    string Title,
    string Status,
    string Snippet);

public sealed record TicketSearchResult(
    string Outcome,
    IReadOnlyList<TicketSummary> Items,
    bool HasMore = false,
    string? NextCursor = null,
    string? Message = null);

public sealed class TicketSearchTool(
    ITicketSearch search,
    Tokenizer tokenizer,
    JsonSerializerOptions serializerOptions)
{
    // Example policy values. Tune them for the model's context window
    // and the prompt budget reserved for tool results.
    private const int MaxTicketsPerResult = 8;
    private const int MaxSnippetCharacters = 600;
    private const int MaxToolResultTokens = 1_500;

    public async Task<TicketSearchResult> SearchTicketsAsync(
        string query,
        string? cursor = null,
        CancellationToken cancellationToken = default)
    {
        var page = await search.FindSummariesAsync(
            query,
            cursor,
            take: MaxTicketsPerResult,
            maxSnippetCharacters: MaxSnippetCharacters,
            cancellationToken: cancellationToken);

        var result = new TicketSearchResult(
            Outcome: "Success",
            Items: page.Items,
            HasMore: page.HasMore,
            NextCursor: page.NextCursor);

        if (Fits(result))
        {
            return result;
        }

        var rejected = new TicketSearchResult(
            Outcome: "ResultTooLarge",
            Items: [],
            Message: "Narrow the query or request fewer details.");

        return Fits(rejected)
            ? rejected
            : throw new InvalidOperationException(
                "The minimal tool result exceeds its token budget.");
    }

    private bool Fits(TicketSearchResult result)
    {
        string json = JsonSerializer.Serialize(result, serializerOptions);
        return tokenizer.CountTokens(json) <= MaxToolResultTokens;
    }
}

var toolJson = new JsonSerializerOptions(AIJsonUtilities.DefaultOptions)
{
    WriteIndented = false
};

toolJson.MakeReadOnly();

var searchTool = new TicketSearchTool(search, tokenizer, toolJson);

AIFunction function = AIFunctionFactory.Create(
    searchTool.SearchTicketsAsync,
    new AIFunctionFactoryOptions
    {
        Name = "search_tickets",
        Description = "Searches support tickets and returns a bounded summary page.",
        SerializerOptions = toolJson
    });

toolJson keeps the Microsoft.Extensions.AI defaults but disables indentation. Pretty-printed JSON gives the model no extra information and spends more tokens. The same read-only instance is used for counting and for function construction, so both operations follow one configuration.

The oversized path returns ResultTooLarge without echoing the failed cursor or pretending that a continuation page exists. It is counted too. Do not cut the JSON string at an arbitrary character. That can break the structure and make omissions hard to spot.

This check covers the serialized function value, not the complete prompt. Leave room for message framing, instructions, conversation history, tool schemas, and the model response. Parallel tools need one shared allowance. Otherwise, each result can fit on its own while their combined payload still exceeds the budget.

Token counts are model-specific, so configure the tokenizer for the deployed model. Provider framing may still differ from what the application can count locally. Treat that part as an estimate and leave headroom for it.

This limit controls how much one tool adds to the next request. A separate iteration cap bounds the tool loop, and the execution-wide runtime budget decides whether the system may start another attempt.