mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-27 01:53:36 +00:00
Cover the streamed tool calling rounds with tests
This commit is contained in:
parent
2c60715031
commit
9a7dc82af8
@ -21,6 +21,7 @@ namespace AIStudio.Provider.OpenAI;
|
||||
public sealed class ChatCompletionToolCallAccumulator(Func<ServerSentEvent, IList<ISource>>? readSources = null)
|
||||
{
|
||||
private const string DONE = "[DONE]";
|
||||
private const string EMPTY_ARGUMENTS = "{}";
|
||||
|
||||
private readonly StringBuilder text = new();
|
||||
private readonly StringBuilder reasoning = new();
|
||||
@ -204,9 +205,13 @@ public sealed class ChatCompletionToolCallAccumulator(Func<ServerSentEvent, ILis
|
||||
/// Builds the call in the shape a non-streamed answer would have carried it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Nothing is corrected here. A call without an ID, without a name, or with arguments
|
||||
/// which are not an object stays as it is, so that the adapter sees what the model
|
||||
/// actually sent and can answer it the way an invalid call has to be answered.
|
||||
/// A call without an ID, without a name, or with arguments which are not an object stays
|
||||
/// as it is: the adapter has to see what the model actually sent, so that it can reject
|
||||
/// the call the way an invalid one has to be rejected.<br/><br/>
|
||||
/// Empty arguments are the one exception, and they are not a correction but a
|
||||
/// translation: a tool which takes nothing gets no fragment at all here, while the same
|
||||
/// call arrives as an empty object when it is not streamed. Handing on the empty string
|
||||
/// would have every parameterless tool rejected as invalid.
|
||||
/// </remarks>
|
||||
public ChatCompletionToolCall Build() => new()
|
||||
{
|
||||
@ -215,7 +220,7 @@ public sealed class ChatCompletionToolCallAccumulator(Func<ServerSentEvent, ILis
|
||||
Function = new ChatCompletionToolFunction
|
||||
{
|
||||
Name = this.Name,
|
||||
Arguments = this.Arguments.ToString(),
|
||||
Arguments = this.Arguments.Length is 0 ? EMPTY_ARGUMENTS : this.Arguments.ToString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,238 @@
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Provider.Anthropic;
|
||||
|
||||
namespace AIStudio.Tests.Provider.ToolCalling;
|
||||
|
||||
/// <summary>
|
||||
/// Checks how a streamed Anthropic message is put back together.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The blocks of a message do not only have to be readable afterwards, they have to be sendable:
|
||||
/// they go back to Anthropic with the next round. A thinking block is the sharp edge -- its
|
||||
/// signature has to return byte for byte with the text it was made for, or the provider refuses
|
||||
/// the continuation with a 400 and the whole conversation is stuck.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class AnthropicMessageStreamAccumulatorTests
|
||||
{
|
||||
[Test]
|
||||
public void ATextBlockIsTheFragmentsItArrivedIn()
|
||||
{
|
||||
var response = Read(
|
||||
"""{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}""",
|
||||
"""{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Let me "}}""",
|
||||
"""{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"look that "}}""",
|
||||
"""{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"up."}}""",
|
||||
"""{"type":"content_block_stop","index":0}""",
|
||||
"""{"type":"message_stop"}""");
|
||||
|
||||
Assert.That(response!.GetTextOutput(), Is.EqualTo("Let me look that up."), "The fragments are joined in order and with nothing in between.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheTextIsShownWhileItIsBeingWritten()
|
||||
{
|
||||
var accumulator = new AnthropicMessageStreamAccumulator();
|
||||
var shown = string.Concat(Lines(
|
||||
"""{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}""",
|
||||
"""{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hel"}}""",
|
||||
"""{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"lo"}}""")
|
||||
.Select(accumulator.Process)
|
||||
.Where(part => part.HasContent)
|
||||
.Select(part => part.TextDelta));
|
||||
|
||||
Assert.That(shown, Is.EqualTo("Hello"), "Each piece of text goes out as it arrives rather than at the end of the block.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ThinkingNeverReachesTheUser()
|
||||
{
|
||||
//
|
||||
// Neither path has ever shown thinking, and making it visible would be a feature of its
|
||||
// own rather than something that happens by accident while streaming.
|
||||
//
|
||||
var accumulator = new AnthropicMessageStreamAccumulator();
|
||||
var shown = Lines(
|
||||
"""{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}""",
|
||||
"""{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Let me consider this."}}""")
|
||||
.Select(accumulator.Process)
|
||||
.Any(part => part.HasContent);
|
||||
|
||||
Assert.That(shown, Is.False, "What the model thinks stays between it and the next round.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AThinkingBlockKeepsItsSignature()
|
||||
{
|
||||
//
|
||||
// The test which nails down the sharpest risk of this change: text and signature have to
|
||||
// come back exactly as they were sent, or the next round is refused.
|
||||
//
|
||||
var response = Read(
|
||||
"""{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}""",
|
||||
"""{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Weighing "}}""",
|
||||
"""{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"the options."}}""",
|
||||
"""{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"EqQBCgIYAhIM+abc/DEF=="}}""",
|
||||
"""{"type":"content_block_stop","index":0}""",
|
||||
"""{"type":"message_stop"}""");
|
||||
|
||||
var block = response!.Content.Single();
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(ReadString(block, "type"), Is.EqualTo("thinking"), "The block goes back as the kind it was.");
|
||||
Assert.That(ReadString(block, "thinking"), Is.EqualTo("Weighing the options."), "With the thinking it carried.");
|
||||
Assert.That(ReadString(block, "signature"), Is.EqualTo("EqQBCgIYAhIM+abc/DEF=="), "And with the signature that was made for exactly that text.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ARedactedThinkingBlockGoesBackUntouched()
|
||||
{
|
||||
//
|
||||
// We cannot read it, which is the very reason we must not rewrite it either.
|
||||
//
|
||||
var response = Read(
|
||||
"""{"type":"content_block_start","index":0,"content_block":{"type":"redacted_thinking","data":"EroBCkYIARgCKkBS0mBXJ"}}""",
|
||||
"""{"type":"content_block_stop","index":0}""",
|
||||
"""{"type":"message_stop"}""");
|
||||
|
||||
Assert.That(ReadString(response!.Content.Single(), "data"), Is.EqualTo("EroBCkYIARgCKkBS0mBXJ"), "Whatever we do not understand travels on unchanged.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AToolUseCollectsItsArgumentsFromFragments()
|
||||
{
|
||||
var response = Read(
|
||||
"""{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"web_search","input":{}}}""",
|
||||
"""{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":"}}""",
|
||||
"""{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"weather\"}"}}""",
|
||||
"""{"type":"content_block_stop","index":0}""",
|
||||
"""{"type":"message_stop"}""");
|
||||
|
||||
var toolUse = response!.GetToolUses().Single();
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(toolUse.Id, Is.EqualTo("toolu_1"), "The ID comes from the block as it opened.");
|
||||
Assert.That(toolUse.Name, Is.EqualTo("web_search"), "So does the name.");
|
||||
Assert.That(toolUse.Arguments, Is.EqualTo("""{"query":"weather"}"""), "And the arguments are the fragments joined back together.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AToolWithoutArgumentsGetsAnEmptyObject()
|
||||
{
|
||||
//
|
||||
// Anthropic sends no fragment at all for a tool which takes nothing, and the input field
|
||||
// has to be an object either way.
|
||||
//
|
||||
var response = Read(
|
||||
"""{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"get_time","input":{}}}""",
|
||||
"""{"type":"content_block_stop","index":0}""",
|
||||
"""{"type":"message_stop"}""");
|
||||
|
||||
Assert.That(response!.GetToolUses().Single().Arguments, Is.EqualTo("{}"), "An empty object is what an empty call looks like on the wire.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ArgumentsWhichNeverParsedMakeTheCallInvalidWhileTheBlockStaysWellFormed()
|
||||
{
|
||||
//
|
||||
// Two things have to be true at once here: the provider gets a block it accepts, and the
|
||||
// call is rejected rather than run with arguments the model never finished writing.
|
||||
//
|
||||
var response = Read(
|
||||
"""{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"web_search","input":{}}}""",
|
||||
"""{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"wea"}}""",
|
||||
"""{"type":"content_block_stop","index":0}""",
|
||||
"""{"type":"message_stop"}""");
|
||||
|
||||
var toolUse = response!.GetToolUses().Single();
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(toolUse.Arguments, Is.EqualTo("""{"query":"wea"""), "The call carries what actually arrived, which no tool executor will accept.");
|
||||
Assert.That(ReadRawText(response.Content.Single(), "input"), Is.EqualTo("{}"), "While the block going back to Anthropic carries an object, because anything else would be refused.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ABlockWhoseClosingEventNeverCameIsStillFinished()
|
||||
{
|
||||
var response = Read(
|
||||
"""{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}""",
|
||||
"""{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}""",
|
||||
"""{"type":"message_stop"}""");
|
||||
|
||||
Assert.That(response!.GetTextOutput(), Is.EqualTo("Hello"), "The end of the message ends every block it still has open.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BlocksComeBackInTheOrderTheyWereIndexed()
|
||||
{
|
||||
//
|
||||
// Interleaved on purpose: what decides the order is the index, not the moment a block
|
||||
// happened to be closed.
|
||||
//
|
||||
var response = Read(
|
||||
"""{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}""",
|
||||
"""{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"web_search","input":{}}}""",
|
||||
"""{"type":"content_block_stop","index":1}""",
|
||||
"""{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"First"}}""",
|
||||
"""{"type":"content_block_stop","index":0}""",
|
||||
"""{"type":"message_stop"}""");
|
||||
|
||||
Assert.That(response!.Content.Select(block => ReadString(block, "type")), Is.EqualTo(new[] { "text", "tool_use" }), "The order of a message is the order of its indices.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AMessageWhichOnlyEndedWithAStopReasonCountsAsFinished()
|
||||
{
|
||||
//
|
||||
// Not every gateway closes with the message stop event, so the stop reason ends the
|
||||
// message as well.
|
||||
//
|
||||
var response = Read(
|
||||
"""{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}""",
|
||||
"""{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}""",
|
||||
"""{"type":"content_block_stop","index":0}""",
|
||||
"""{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}""");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(response, Is.Not.Null, "A message with a stop reason is a message which ended.");
|
||||
Assert.That(response!.StopReason, Is.EqualTo("end_turn"), "And the reason it ended travels with it.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AStreamCutOffMidSentenceIsAFailedRound()
|
||||
{
|
||||
//
|
||||
// No stop event and no stop reason: whatever was streamed stays on screen, but there is
|
||||
// no round to continue from.
|
||||
//
|
||||
var response = Read(
|
||||
"""{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}""",
|
||||
"""{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hel"}}""");
|
||||
|
||||
Assert.That(response, Is.Null, "An unfinished message is not handed on as if it were finished.");
|
||||
}
|
||||
|
||||
private static AnthropicResponse? Read(params string[] data)
|
||||
{
|
||||
var accumulator = new AnthropicMessageStreamAccumulator();
|
||||
foreach (var serverSentEvent in Lines(data))
|
||||
accumulator.Process(serverSentEvent);
|
||||
|
||||
return accumulator.Build();
|
||||
}
|
||||
|
||||
private static IEnumerable<ServerSentEvent> Lines(params string[] data) => data.Select(Event);
|
||||
|
||||
private static ServerSentEvent Event(string data) => new($"data: {data}", data);
|
||||
|
||||
private static string ReadString(JsonElement block, string propertyName) => block.TryGetProperty(propertyName, out var property) ? property.GetString() ?? string.Empty : string.Empty;
|
||||
|
||||
private static string ReadRawText(JsonElement block, string propertyName) => block.TryGetProperty(propertyName, out var property) ? property.GetRawText() : string.Empty;
|
||||
}
|
||||
@ -0,0 +1,199 @@
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Tools;
|
||||
using AIStudio.Provider.OpenAI;
|
||||
|
||||
namespace AIStudio.Tests.Provider.ToolCalling;
|
||||
|
||||
/// <summary>
|
||||
/// Checks how a streamed Chat Completions answer is put back together.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Seventeen providers share this one path, and they disagree on nearly every detail of it: some
|
||||
/// send the index with every fragment, some only with the first, some send no index at all, and
|
||||
/// not all of them close the stream with a "[DONE]". Each of those is one case below, because
|
||||
/// each of them is one provider whose tool calls would otherwise fall apart.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class ChatCompletionToolCallAccumulatorTests
|
||||
{
|
||||
[Test]
|
||||
public void ArgumentsSpreadOverManyFragmentsBecomeOneCall()
|
||||
{
|
||||
var message = Read(
|
||||
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"web_search","arguments":"{\"qu"}}]}}]}""",
|
||||
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ery\":"}}]}}]}""",
|
||||
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"wea"}}]}}]}""",
|
||||
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ther\""}}]}}]}""",
|
||||
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"}"}}]}}]}""",
|
||||
"[DONE]");
|
||||
|
||||
var call = message!.ToolCalls!.Single()!;
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(call.Id, Is.EqualTo("call_1"), "The ID arrived with the first fragment and belongs to the whole call.");
|
||||
Assert.That(call.Function!.Name, Is.EqualTo("web_search"), "So does the name.");
|
||||
Assert.That(call.Function!.Arguments, Is.EqualTo("""{"query":"weather"}"""), "And the arguments are the fragments in the order they came, joined without anything in between.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TwoCallsWrittenAtTheSameTimeStayApart()
|
||||
{
|
||||
//
|
||||
// Nothing says a model finishes one call before it starts the next, and the index is
|
||||
// what keeps the fragments of the two from running into each other.
|
||||
//
|
||||
var message = Read(
|
||||
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","function":{"name":"web_search","arguments":"{\"query\":"}}]}}]}""",
|
||||
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_b","function":{"name":"read_web_page","arguments":"{\"url\":"}}]}}]}""",
|
||||
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"a\"}"}}]}}]}""",
|
||||
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"\"b\"}"}}]}}]}""");
|
||||
|
||||
Assert.That(message!.ToolCalls!.Select(x => $"{x!.Id}:{x.Function!.Arguments}"), Is.EqualTo(new[]
|
||||
{
|
||||
"""call_a:{"query":"a"}""",
|
||||
"""call_b:{"url":"b"}""",
|
||||
}), "Each call collects its own fragments, whichever order they arrive in.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AProviderWhichSendsNoIndexStillGetsAWholeCall()
|
||||
{
|
||||
//
|
||||
// Some gateways leave the index out once the call is open. What is left to correlate by
|
||||
// is the ID, and after that the call which was opened last.
|
||||
//
|
||||
var message = Read(
|
||||
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_1","function":{"name":"web_search","arguments":"{\"query\":"}}]}}]}""",
|
||||
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"function":{"arguments":"\"weather\"}"}}]}}]}""");
|
||||
|
||||
var call = message!.ToolCalls!.Single()!;
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(call.Id, Is.EqualTo("call_1"), "One call, not two: a fragment without an index belongs to the one being written.");
|
||||
Assert.That(call.Function!.Arguments, Is.EqualTo("""{"query":"weather"}"""), "And its arguments are complete.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AWholeCallInOneFragmentWorksJustAsWell()
|
||||
{
|
||||
var message = Read("""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"web_search","arguments":"{\"query\":\"weather\"}"}}]},"finish_reason":"tool_calls"}]}""");
|
||||
|
||||
var call = message!.ToolCalls!.Single()!;
|
||||
Assert.That(call.Function!.Arguments, Is.EqualTo("""{"query":"weather"}"""), "Fragmenting is what providers may do, not what they must do.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TextAndAToolCallInTheSameRoundBothSurvive()
|
||||
{
|
||||
//
|
||||
// The preamble case on the wire: the model says what it is going to do and then does it.
|
||||
//
|
||||
var accumulator = new ChatCompletionToolCallAccumulator();
|
||||
var shown = string.Concat(Lines(
|
||||
"""{"choices":[{"index":0,"delta":{"content":"Let me look "}}]}""",
|
||||
"""{"choices":[{"index":0,"delta":{"content":"that up."}}]}""",
|
||||
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"web_search","arguments":"{}"}}]}}]}""")
|
||||
.Select(accumulator.Process)
|
||||
.Where(part => part.HasContent)
|
||||
.Select(part => part.TextDelta));
|
||||
|
||||
var message = accumulator.Build();
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(shown, Is.EqualTo("Let me look that up."), "The text goes out while it is being written, in the pieces it arrives in.");
|
||||
Assert.That(message!.Content, Is.EqualTo("Let me look that up."), "And the same text goes back to the provider as what the model said.");
|
||||
Assert.That(message.ToolCalls!.Single()!.Id, Is.EqualTo("call_1"), "The tool call of that round is there as well.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ContentSentAsPartsIsReadAsText()
|
||||
{
|
||||
// Some gateways send the content the way a request carries it, as a list of parts:
|
||||
var message = Read("""{"choices":[{"index":0,"delta":{"content":[{"type":"text","text":"Hello"}]}}]}""");
|
||||
|
||||
Assert.That(message!.Content, Is.EqualTo("Hello"), "A provider which sends parts instead of a string is still sending text.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReasoningTravelsSeparatelyFromTheAnswer()
|
||||
{
|
||||
var message = Read(
|
||||
"""{"choices":[{"index":0,"delta":{"reasoning_content":"Thinking about it."}}]}""",
|
||||
"""{"choices":[{"index":0,"delta":{"content":"The answer."}}]}""");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(message!.ReasoningContent, Is.EqualTo("Thinking about it."), "Reasoning is kept, because the next request is charged for it.");
|
||||
Assert.That(message.Content, Is.EqualTo("The answer."), "And it is not mixed into the answer.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AToolWhichTakesNothingGetsAnEmptyObject()
|
||||
{
|
||||
//
|
||||
// A parameterless tool is called without a single argument fragment, while the very same
|
||||
// call carries an empty object when it is not streamed. Handing on the empty string here
|
||||
// would have every one of those calls rejected as invalid.
|
||||
//
|
||||
var withoutAnyFragment = Read("""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_time"}}]}}]}""");
|
||||
var withAnEmptyFragment = Read("""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_time","arguments":""}}]}}]}""");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(withoutAnyFragment!.ToolCalls!.Single()!.Function!.Arguments, Is.EqualTo("{}"), "No fragment at all is a call without arguments, not a broken one.");
|
||||
Assert.That(withAnEmptyFragment!.ToolCalls!.Single()!.Function!.Arguments, Is.EqualTo("{}"), "And neither is the empty fragment some providers send instead.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ARoundWithoutTextHasNoContentAtAll()
|
||||
{
|
||||
//
|
||||
// An empty string in place of the missing content is rejected by some providers, so the
|
||||
// field has to be absent exactly as it is in a non-streamed answer.
|
||||
//
|
||||
var message = Read("""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"web_search","arguments":"{}"}}]}}]}""");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(message!.RawContent, Is.Null, "No text means no content field.");
|
||||
Assert.That(message.Content, Is.Null, "Which is what the adapter reads as an answer without words.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AStreamWhichSaidNothingIsAFailedRound()
|
||||
{
|
||||
Assert.That(new ChatCompletionToolCallAccumulator().Build(), Is.Null, "A request that failed leaves no lines behind, and a round without a message ends the loop without a second error message.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SourcesOfTheProviderTravelWithTheirLine()
|
||||
{
|
||||
//
|
||||
// Perplexity puts its search results next to the text rather than on a line of their own,
|
||||
// which is why the sources are read through the provider's own types.
|
||||
//
|
||||
var accumulator = new ChatCompletionToolCallAccumulator(_ => [new Source("Example", "https://example.org/", SourceOrigin.LLM)]);
|
||||
var part = accumulator.Process(Event("""{"choices":[{"index":0,"delta":{"content":"Hello"}}]}"""));
|
||||
|
||||
Assert.That(part.Sources.Select(x => x.URL), Is.EqualTo(new[] { "https://example.org/" }), "Whatever the provider announced on that line reaches the user with it.");
|
||||
}
|
||||
|
||||
private static ChatCompletionResponseMessage? Read(params string[] data)
|
||||
{
|
||||
var accumulator = new ChatCompletionToolCallAccumulator();
|
||||
foreach (var serverSentEvent in Lines(data))
|
||||
accumulator.Process(serverSentEvent);
|
||||
|
||||
return accumulator.Build();
|
||||
}
|
||||
|
||||
private static IEnumerable<ServerSentEvent> Lines(params string[] data) => data.Select(Event);
|
||||
|
||||
private static ServerSentEvent Event(string data) => new($"data: {data}", data);
|
||||
}
|
||||
@ -0,0 +1,128 @@
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Provider.OpenAI;
|
||||
|
||||
namespace AIStudio.Tests.Provider.ToolCalling;
|
||||
|
||||
/// <summary>
|
||||
/// Checks how a streamed Responses API call is read back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The API repeats the whole response when it is done, so there is little to reassemble here --
|
||||
/// but there is one thing to get right: the reasoning items have to return exactly as they came,
|
||||
/// including the parts we do not understand. The API refuses a continuation whose reasoning is
|
||||
/// missing, and it would just as surely refuse one we rewrote.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class ResponsesStreamAccumulatorTests
|
||||
{
|
||||
private const string REASONING_ITEM = """{"type":"reasoning","id":"rs_1","summary":[],"encrypted_content":"gAAAAAB0aXRs"}""";
|
||||
private const string COMPLETED_PREFIX = """{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5","output":[""";
|
||||
|
||||
[Test]
|
||||
public void TheReasoningItemComesBackWordForWord()
|
||||
{
|
||||
//
|
||||
// The test that nails down the main risk: whatever the reasoning item carries, including
|
||||
// fields nobody here knows about, is what goes back on the next request.
|
||||
//
|
||||
var response = Read(COMPLETED_PREFIX + REASONING_ITEM + "]}}");
|
||||
|
||||
Assert.That(response!.Output.Single().GetRawText(), Is.EqualTo(REASONING_ITEM), "Not a field added, not a field dropped: the item travels on as it arrived.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheCompletedEventCarriesTheWholeRound()
|
||||
{
|
||||
var response = Read(COMPLETED_PREFIX + REASONING_ITEM + "," +
|
||||
"""{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Here is the answer."}]},""" +
|
||||
"""{"type":"function_call","call_id":"call_1","name":"web_search","arguments":"{\"query\":\"weather\"}"}""" +
|
||||
"]}}");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(response!.GetTextOutput(), Is.EqualTo("Here is the answer."), "The text of the round is read out of the completed response.");
|
||||
Assert.That(response.GetFunctionCalls().Single().CallId, Is.EqualTo("call_1"), "And so are the calls it asked for.");
|
||||
Assert.That(response.Output, Has.Count.EqualTo(3), "Every output item is kept, because every one of them goes back.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheTextIsShownWhileItIsBeingWritten()
|
||||
{
|
||||
var accumulator = new ResponsesStreamAccumulator();
|
||||
var shown = string.Concat(Lines(
|
||||
"""{"type":"response.output_text.delta","delta":"Let me "}""",
|
||||
"""{"type":"response.output_text.delta","delta":"look that up."}""")
|
||||
.Select(accumulator.Process)
|
||||
.Where(part => part.HasContent)
|
||||
.Select(part => part.TextDelta));
|
||||
|
||||
Assert.That(shown, Is.EqualTo("Let me look that up."), "Each piece of text goes out as it arrives rather than at the end of the round.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnAnnouncedSourceTravelsWithItsLine()
|
||||
{
|
||||
var accumulator = new ResponsesStreamAccumulator();
|
||||
var part = accumulator.Process(Event("""{"type":"response.output_text.annotation.added","annotation_index":0,"annotation":{"type":"url_citation","title":"Example","url":"https://example.org/"}}"""));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(part.Sources.Select(x => x.URL), Is.EqualTo(new[] { "https://example.org/" }), "A citation reaches the user as soon as the model makes it.");
|
||||
Assert.That(part.TextDelta, Is.Empty, "A line which only announces a source carries no text.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AGatewayWithoutACompletedEventStillGetsARound()
|
||||
{
|
||||
//
|
||||
// Not every gateway in front of this API sends the closing event. The finished output
|
||||
// items are enough to put the round back together, reasoning included.
|
||||
//
|
||||
var response = Read(
|
||||
"""{"type":"response.output_item.done","output_index":0,"item":""" + REASONING_ITEM + "}",
|
||||
"""{"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","call_id":"call_1","name":"web_search","arguments":"{}"}}""");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(response, Is.Not.Null, "A round built from its items is still a round.");
|
||||
Assert.That(response!.Output.First().GetRawText(), Is.EqualTo(REASONING_ITEM), "And the reasoning item is as untouched as it would be in the completed event.");
|
||||
Assert.That(response.GetFunctionCalls().Single().Name, Is.EqualTo("web_search"), "The call is there to be executed.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheCompletedEventWinsOverTheCollectedItems()
|
||||
{
|
||||
//
|
||||
// When both arrive, the response the API itself assembled is the one to trust.
|
||||
//
|
||||
var response = Read(
|
||||
"""{"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Partial"}]}}""",
|
||||
"""{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Complete"}]}]}}""");
|
||||
|
||||
Assert.That(response!.GetTextOutput(), Is.EqualTo("Complete"), "The closing event is the round, and the collected items were only there in case it never came.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AStreamWhichSaidNothingIsAFailedRound()
|
||||
{
|
||||
var response = Read("""{"type":"response.created","response":{"id":"resp_1"}}""");
|
||||
|
||||
Assert.That(response, Is.Null, "Neither a completed response nor a single finished item: there is no round here to continue from.");
|
||||
}
|
||||
|
||||
private static ResponsesResponse? Read(params string[] data)
|
||||
{
|
||||
var accumulator = new ResponsesStreamAccumulator();
|
||||
foreach (var serverSentEvent in Lines(data))
|
||||
accumulator.Process(serverSentEvent);
|
||||
|
||||
return accumulator.Build();
|
||||
}
|
||||
|
||||
private static IEnumerable<ServerSentEvent> Lines(params string[] data) => data.Select(Event);
|
||||
|
||||
private static ServerSentEvent Event(string data) => new($"data: {data}", data);
|
||||
}
|
||||
325
app/Tests/Tools/ToolCalling/ToolCallingLoopTests.cs
Normal file
325
app/Tests/Tools/ToolCalling/ToolCallingLoopTests.cs
Normal file
@ -0,0 +1,325 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Checks what the tool calling loop puts on screen while a model works through its tools.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.<br/><br/>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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.");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// As many rounds calling one tool each as it takes to use up the tool budget.
|
||||
/// </summary>
|
||||
private static List<IReadOnlyList<ToolCallingStreamEvent>> ExhaustTheToolBudget() => Enumerable
|
||||
.Range(0, ToolSelectionRules.MAX_TOOL_CALLS)
|
||||
.Select(IReadOnlyList<ToolCallingStreamEvent> (round) => [Completed(string.Empty, [Call($"call-{round}")])])
|
||||
.ToList();
|
||||
|
||||
private static ToolCallingStreamEvent Text(string text) => ToolCallingStreamEvent.TextDelta(text);
|
||||
|
||||
private static ToolCallingStreamEvent Completed(string text, IReadOnlyList<ToolCallingRequestedCall>? calls = null, IReadOnlyList<ISource>? sources = null)
|
||||
=> ToolCallingStreamEvent.RoundCompleted(new ToolCallingRound(text, calls ?? [], sources ?? []));
|
||||
|
||||
private static ToolCallingRequestedCall Call(string callId) => new(callId, "some_tool", "{}", true);
|
||||
|
||||
private static async Task<string> Run(ScriptedAdapter adapter) => string.Concat((await Collect(adapter)).Select(chunk => chunk.Content));
|
||||
|
||||
private static async Task<List<ContentStreamChunk>> Collect(ScriptedAdapter adapter, CancellationToken token = default)
|
||||
{
|
||||
var loop = new ToolCallingLoop(NullLogger<ToolCallingLoop>.Instance);
|
||||
var chunks = new List<ContentStreamChunk>();
|
||||
await foreach (var chunk in loop.RunAsync(adapter, CreateContext(), token))
|
||||
chunks.Add(chunk);
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A context which needs nothing of the application around it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
private static ToolCallingLoopContext CreateContext() => new()
|
||||
{
|
||||
ChatThread = new(),
|
||||
RunnableTools = [],
|
||||
ToolExecutor = new(null!, NullLogger<ToolExecutor>.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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An adapter which plays back a script of events, one list per round.
|
||||
/// </summary>
|
||||
private sealed class ScriptedAdapter(params IReadOnlyList<ToolCallingStreamEvent>[] rounds) : IToolCallingProviderAdapter
|
||||
{
|
||||
private readonly Queue<IReadOnlyList<ToolCallingStreamEvent>> remainingRounds = new(rounds);
|
||||
|
||||
/// <summary>
|
||||
/// When set, the run is cancelled right after the first event of the first round, the way
|
||||
/// a user pressing stop cancels one.
|
||||
/// </summary>
|
||||
public CancellationTokenSource? CancelAfterFirstEvent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// What the loop recorded, in the order it did.
|
||||
/// </summary>
|
||||
public List<string> Recordings { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> RecordedRequestTexts => [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<ToolCallingStreamEvent> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RecordAssistantTurn() => this.Recordings.Add("turn");
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RecordToolResult(string callId, string content, bool isError = false) => this.Recordings.Add($"result:{callId}");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user