using System.Runtime.CompilerServices; using AIStudio.Provider; using AIStudio.Tools; using AIStudio.Tools.ToolCallingSystem; using AIStudio.Tools.ToolCallingSystem.Harness; using Microsoft.Extensions.Logging.Abstractions; namespace AIStudio.Tests.Tools.ToolCalling; /// /// Checks what the tool calling loop puts on screen while a model works through its tools. /// /// /// Two things decide whether this loop behaves: every word the model writes has to arrive, and it /// has to arrive once. Both used to be free -- the round's text was shown at its end, and there /// was nothing else it could have come from. Now the text streams out while the round runs and /// the round still reports it afterwards, so the one thing that must never happen is showing it /// twice. The other side of the same coin is the preamble a model writes before it calls a tool, /// which was dropped entirely before and is the reason for this whole change.

/// The adapter is scripted rather than real: what a provider puts on the wire is checked in the /// accumulator tests, while this is about the loop in between. ///
[TestFixture] public sealed class ToolCallingLoopTests { private const string PREAMBLE = "Let me look that up."; private const string ANSWER = "Here is the answer."; private const string SEPARATOR = "\n\n"; private const string NO_ANSWER = "did not return a final answer"; [Test] public async Task APreambleReachesTheUserAlthoughItsRoundOnlyCalledATool() { // // The regression this whole change is about: a model which says what it is about to do // before it does it. That sentence never left the provider layer. // var adapter = new ScriptedAdapter( [Text(PREAMBLE), Completed(PREAMBLE, [Call("call-1")])], [Text(ANSWER), Completed(ANSWER)]); var written = await Run(adapter); Assert.That(written, Does.StartWith(PREAMBLE), "What the model says before it calls a tool is the first thing the user reads, not something we keep to ourselves."); } [Test] public async Task EveryTextIsWrittenExactlyOnce() { // // The one way this can go wrong: the round reports the same text its deltas already // carried, and the answer ends up on screen twice. // var adapter = new ScriptedAdapter( [Text(PREAMBLE), Completed(PREAMBLE, [Call("call-1")])], [Text(ANSWER), Completed(ANSWER)]); var written = await Run(adapter); Assert.Multiple(() => { Assert.That(Occurrences(written, PREAMBLE), Is.EqualTo(1), "The preamble streamed out; the round reporting it again must not put it on screen a second time."); Assert.That(Occurrences(written, ANSWER), Is.EqualTo(1), "The same goes for the final answer, which is where a duplicate would be most visible."); }); } [Test] public async Task OnlyARoundWhichSpeaksGetsASeparator() { // // A round which does nothing but call a tool must not leave a gap behind: the separator // belongs between two texts, not after every round. // var afterSpeaking = await Run(new ScriptedAdapter( [Text(PREAMBLE), Completed(PREAMBLE, [Call("call-1")])], [Text(ANSWER), Completed(ANSWER)])); var afterSilence = await Run(new ScriptedAdapter( [Completed(string.Empty, [Call("call-1")])], [Text(ANSWER), Completed(ANSWER)])); Assert.Multiple(() => { Assert.That(afterSpeaking, Is.EqualTo($"{PREAMBLE}{SEPARATOR}{ANSWER}"), "Two texts from two rounds are two paragraphs, not one run-on sentence."); Assert.That(afterSilence, Is.EqualTo(ANSWER), "Nothing was said before, so there is nothing to separate from."); }); } [Test] public async Task TheLimitMessageOnlyAppearsWhenTheLastRoundSaidNothing() { // // Reaching the limit means the model is asked for a final answer without tools. When it // gives one, that answer has already streamed out -- and the message about not having // answered has to stay away. // var answering = await Run(new ScriptedAdapter([..ExhaustTheToolBudget(), [Text(ANSWER), Completed(ANSWER)]])); var silent = await Run(new ScriptedAdapter([..ExhaustTheToolBudget(), [Completed(string.Empty)]])); Assert.Multiple(() => { Assert.That(answering, Does.EndWith(ANSWER).And.Not.Contains(NO_ANSWER), "The model answered, so nothing has to be said on its behalf."); Assert.That(silent, Does.Contain(NO_ANSWER), "It stayed silent after using up its tools, and silence would look like a hung request."); }); } [Test] public async Task TheNoAnswerMessageOnlyAppearsWhenTheRoundSaidNothing() { var answering = await Run(new ScriptedAdapter( [Completed(string.Empty, [Call("call-1")])], [Text(ANSWER), Completed(ANSWER)])); var silent = await Run(new ScriptedAdapter( [Completed(string.Empty, [Call("call-1")])], [Completed(string.Empty)])); Assert.Multiple(() => { Assert.That(answering, Is.EqualTo(ANSWER), "There is an answer, so the fallback message has no place here."); Assert.That(silent, Does.Contain(NO_ANSWER), "The tool ran and nothing came of it, which the user has to be told."); }); } [Test] public async Task TheSourcesArriveAlthoughTheFinalTextNoLongerDoes() { // // The last round hands over an empty chunk carrying the sources, because its text went // out as deltas. Forget that chunk and the citation links of a web search disappear. // var source = new Source("Example", "https://example.org/", SourceOrigin.LLM); var adapter = new ScriptedAdapter( [Completed(string.Empty, [Call("call-1")], [source])], [Text(ANSWER), Completed(ANSWER)]); var chunks = await Collect(adapter); Assert.That(chunks.SelectMany(chunk => chunk.Sources).Select(x => x.URL), Does.Contain("https://example.org/"), "The sources of a round reach the caller even when its text does not."); } [Test] public async Task ARoundWhichNeverCompletesEndsQuietly() { // // A stream cut off mid-sentence, or a request which failed: the adapter has told the user // what went wrong already, so the loop adds nothing of its own. // var adapter = new ScriptedAdapter([Text(PREAMBLE)]); var chunks = await Collect(adapter); Assert.Multiple(() => { Assert.That(string.Concat(chunks.Select(x => x.Content)), Is.EqualTo(PREAMBLE), "What was streamed stays; nothing is taken back."); Assert.That(chunks.Select(x => x.Content), Has.None.Contains(NO_ANSWER), "An error message on top of the adapter's own would say the same thing twice."); }); } [Test] public async Task ACallWithoutAnIdEndsTheConversation() { // // The result is correlated by that ID. Inventing one has the next request rejected, so // there is nothing to salvage from a round like this. // var written = await Run(new ScriptedAdapter( [Completed(string.Empty, [Call(string.Empty)])], [Text(ANSWER), Completed(ANSWER)])); Assert.Multiple(() => { Assert.That(written, Does.Contain("The tool call was invalid."), "The user learns why the answer stops here."); Assert.That(written, Does.Not.Contain(ANSWER), "And the loop does not carry on into a round the provider would refuse."); }); } [Test] public async Task TheModelsTurnIsRecordedOncePerRoundAndBeforeItsResults() { // // The provider has to know about the turn before it is sent results for it, and recording // it twice would send the same tool call twice. // var adapter = new ScriptedAdapter( [Text(PREAMBLE), Completed(PREAMBLE, [Call("call-1"), Call("call-2")])], [Text(ANSWER), Completed(ANSWER)]); await Run(adapter); Assert.That(adapter.Recordings, Is.EqualTo(new[] { "turn", "result:call-1", "result:call-2" }), "One turn, then its results, in the order the model asked for them."); } [Test] public async Task ACancelledStreamStopsTheLoopWhereItIs() { // // What the user sees when they press stop. The provider's stream reader ends quietly on // a cancellation rather than throwing, so the round reaches its end without completing -- // which has to leave the text alone and add nothing to it. // using var cancellation = new CancellationTokenSource(); var adapter = new ScriptedAdapter([Text(PREAMBLE), Text(ANSWER), Completed(ANSWER)]) { CancelAfterFirstEvent = cancellation, }; var chunks = await Collect(adapter, cancellation.Token); Assert.Multiple(() => { Assert.That(string.Concat(chunks.Select(x => x.Content)), Is.EqualTo(PREAMBLE), "Everything written before the stop stays, and nothing after it arrives."); 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."); }); } /// /// As many rounds calling one tool each as it takes to use up the tool budget. /// private static List> ExhaustTheToolBudget() => Enumerable .Range(0, ToolSelectionRules.MAX_TOOL_CALLS) .Select(IReadOnlyList (round) => [Completed(string.Empty, [Call($"call-{round}")])]) .ToList(); private static ToolCallingStreamEvent Text(string text) => ToolCallingStreamEvent.TextDelta(text); private static ToolCallingStreamEvent Completed(string text, IReadOnlyList? calls = null, IReadOnlyList? sources = null) => ToolCallingStreamEvent.RoundCompleted(new ToolCallingRound(text, calls ?? [], sources ?? [])); private static ToolCallingRequestedCall Call(string callId) => new(callId, "some_tool", "{}", true); private static async Task Run(ScriptedAdapter adapter) => string.Concat((await Collect(adapter)).Select(chunk => chunk.Content)); private static async Task> Collect(ScriptedAdapter adapter, CancellationToken token = default) { var loop = new ToolCallingLoop(NullLogger.Instance); var chunks = new List(); await foreach (var chunk in loop.RunAsync(adapter, CreateContext(), token)) chunks.Add(chunk); return chunks; } /// /// A context which needs nothing of the application around it. /// /// /// Without an assistant message, every UI call of the context returns right away, which is /// what keeps the service provider out of these tests. The tool executor gets no settings /// service for the same reason: with no runnable tools, every call ends as blocked long /// before any setting is read. /// private static ToolCallingLoopContext CreateContext() => new() { ChatThread = new(), RunnableTools = [], ToolExecutor = new(null!, NullLogger.Instance), Provider = new NoProvider(), CurrentAssistantContent = null, ProviderInstanceName = "Test provider", ProviderType = LLMProviders.NONE, ModelId = "test-model", }; private static int Occurrences(string text, string part) { var count = 0; for (var index = text.IndexOf(part, StringComparison.Ordinal); index >= 0; index = text.IndexOf(part, index + part.Length, StringComparison.Ordinal)) count++; return count; } /// /// An adapter which plays back a script of events, one list per round. /// private sealed class ScriptedAdapter(params IReadOnlyList[] rounds) : IToolCallingProviderAdapter { private readonly Queue> remainingRounds = new(rounds); /// /// When set, the run is cancelled right after the first event of the first round, the way /// a user pressing stop cancels one. /// public CancellationTokenSource? CancelAfterFirstEvent { get; init; } /// /// What the loop recorded, in the order it did. /// public List Recordings { get; } = []; /// public IReadOnlyList RecordedRequestTexts => []; /// public async IAsyncEnumerable ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, [EnumeratorCancellation] CancellationToken token = default) { await Task.Yield(); if (this.remainingRounds.Count is 0) yield break; foreach (var streamEvent in this.remainingRounds.Dequeue()) { // // Ending rather than throwing, which is what the shared stream reader does when a // cancellation reaches it: it stops reading lines and lets the round end without // its completed event. // if (token.IsCancellationRequested) yield break; yield return streamEvent; this.CancelAfterFirstEvent?.Cancel(); } } /// public void RecordAssistantTurn() => this.Recordings.Add("turn"); /// public void RecordToolResult(string callId, string content, bool isError = false) => this.Recordings.Add($"result:{callId}"); } }