Moved the first round rule of the token count into the tool calling loop

This commit is contained in:
Thorsten Sommer committed 2026-09-24 12:39:40 +02:00
1 parent 0c585144e2
commit a4ac6bc5a0
5 files changed
+87 -31

No files matched your search

@@ -13,11 +13,9 @@ namespace AIStudio.Tests.Provider.ToolCalling;
/// </summary>
/// <remarks>
/// Every round of a tool conversation is a request of its own, and every one of them reports what
/// it cost. Only the first one describes what the next question will be sent after: every later
/// round carries the tool calls and their results on top, none of which is sent again once the
/// answer stands. Passing on the last report instead would put the chat at the size of everything
/// the tools returned, which is the one number a person watching their context window must not
/// see as exact.
/// it cost. The adapter passes on each report as it arrives, the line without choices included.
/// Which of them describes the conversation is not its decision: that is the tool calling loop's,
/// which knows which round this is, and is checked in ToolCallingLoopTests.
///
/// What a round asks for is one tool call at a time, wherever the provider lets it ask: a provider
/// which rejects the question fails the whole request, so it is not asked at all.
@@ -30,7 +28,7 @@ public sealed class ChatCompletionToolCallingAdapterTests
private const string SECOND_ROUND_USAGE = """{"choices":[],"usage":{"prompt_tokens":9800,"completion_tokens":150}}""";
[Test]
public async Task OnlyTheFirstRoundPassesOnWhatItsRequestCost()
public async Task EveryRoundPassesOnWhatItsRequestCost()
{
var adapter = Adapter(
[
@@ -57,8 +55,8 @@ public sealed class ChatCompletionToolCallingAdapterTests
Assert.Multiple(() =>
{
Assert.That(firstRound, Is.EqualTo(new[] { 1200 }), "The first round's prompt is the conversation up to the question.");
Assert.That(secondRound, Is.Empty, "The second round's prompt holds the tool result as well, which the next question is not sent with.");
Assert.That(firstRound, Is.EqualTo(new[] { 1200 }), "The first round reports the conversation up to the question.");
Assert.That(secondRound, Is.EqualTo(new[] { 9800 }), "The second round reports its own request, tool result included, and leaves it to the loop to drop.");
});
}
@@ -215,7 +215,48 @@ public sealed class ToolCallingLoopTests
Assert.That(chunks.Select(x => x.Content), Has.None.Contains(NO_ANSWER), "A stop is not a failure to answer, so it is not reported as one.");
});
}
[Test]
public async Task OnlyTheFirstRoundsUsageReachesTheAnswer()
{
//
// Every round is a request of its own, and every one of them reports what it cost. Only
// the first one describes what the next question will be sent after: every later round
// carries the tool calls and their results on top, none of which is sent again once the
// answer stands.
//
var adapter = new ScriptedAdapter(
[Usage(1200), Completed(string.Empty, [Call("call-1")])],
[Text(ANSWER), Usage(9800), Completed(ANSWER)]);
var usages = (await Collect(adapter)).Where(chunk => chunk.Usage.IsKnown).Select(chunk => chunk.Usage.PromptTokens);
Assert.That(usages, Is.EqualTo(new[] { 1200 }), "The second round's prompt holds the tool result as well, which the next question is not sent with.");
}
[Test]
public async Task ALaterRoundsUsageLeavesItsTextAndNothingElse()
{
//
// Some providers send the usage next to the last piece of text rather than on a line of
// its own. Dropping the usage must not drop that text, and a delta which carried nothing
// but the usage must not turn into an empty chunk of its own.
//
var withUsage = await Collect(new ScriptedAdapter(
[Completed(string.Empty, [Call("call-1")])],
[Usage(9800), Usage(9800, ANSWER), Completed(ANSWER)]));
var withoutUsage = await Collect(new ScriptedAdapter(
[Completed(string.Empty, [Call("call-1")])],
[Text(ANSWER), Completed(ANSWER)]));
Assert.Multiple(() =>
{
Assert.That(withUsage.Select(x => x.Content), Is.EqualTo(withoutUsage.Select(x => x.Content)), "The same chunks arrive as if the round had reported nothing.");
Assert.That(withUsage.Select(x => x.Usage.IsKnown), Has.None.True, "And none of them carries the usage on.");
});
}
/// <summary>
/// As many rounds calling one tool each as it takes to use up the tool budget.
/// </summary>
@@ -225,7 +266,9 @@ public sealed class ToolCallingLoopTests
.ToList();
private static ToolCallingStreamEvent Text(string text) => ToolCallingStreamEvent.TextDelta(text);
private static ToolCallingStreamEvent Usage(int promptTokens, string text = "") => ToolCallingStreamEvent.TextDelta(new ContentStreamChunk(text, [], TokenUsage.Of(promptTokens)));
private static ToolCallingStreamEvent Completed(string text, IReadOnlyList<ToolCallingRequestedCall>? calls = null, IReadOnlyList<ISource>? sources = null)
=> ToolCallingStreamEvent.RoundCompleted(new ToolCallingRound(text, calls ?? [], sources ?? []));