Count the tool conversation in the token count (#973)
Some checks are pending
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Verify (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions

This commit is contained in:
Thorsten Sommer 2026-09-14 19:54:23 +02:00 committed by GitHub
parent f869122070
commit 6ce7d856a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 479 additions and 58 deletions

View File

@ -55,6 +55,49 @@ public sealed class ContentText : IContent
[JsonIgnore] [JsonIgnore]
public ToolRuntimeStatus ToolRuntimeStatus { get; set; } = new(); public ToolRuntimeStatus ToolRuntimeStatus { get; set; } = new();
/// <summary>
/// What the tool conversation of the running request adds to it, as far as it has got.
/// </summary>
/// <remarks>
/// A model which calls tools asks several times before it answers, and every one of those
/// requests carries everything the tools returned so far -- up to three hundred thousand
/// characters of it. None of that is in this block's text, and none of it is in the traces
/// either: those say what happened, not what it costs. So it is kept here, where whoever
/// counts the conversation walks past anyway.<br/><br/>
/// Replaced as a whole, never appended to: it is written by the thread which runs the tools
/// and read by the one which renders, and an exchange leaves the reader with a list which was
/// true at some moment rather than with one being rewritten under it.<br/><br/>
/// Gone when the answer is there, and never persisted. The accumulated tool conversation lives
/// in the provider adapter, which is created for one request and dropped with it -- so the next
/// request does not carry it, and a number which still counted it would promise a cost nobody
/// is going to pay.
/// </remarks>
[JsonIgnore]
public IReadOnlyList<string> PendingToolConversation { get; set; } = [];
/// <summary>
/// Clears what the previous run of the tools left behind.
/// </summary>
/// <remarks>
/// Both parts at once, because both belong to one request: the traces the user reads and the
/// payload the counting needs. They were cleared separately for exactly as long as there was
/// only one of them.
/// </remarks>
public void BeginToolRun()
{
this.ToolInvocations.Clear();
this.PendingToolConversation = [];
}
/// <summary>
/// Says that no request is running anymore.
/// </summary>
/// <remarks>
/// The traces stay -- they are what the user reads afterwards to see how the answer came
/// about. What goes is the payload, which belonged to a request that is over.
/// </remarks>
public void EndToolRun() => this.PendingToolConversation = [];
/// <inheritdoc /> /// <inheritdoc />
public async Task<ChatThread> CreateFromProviderAsync(IProvider provider, Model chatModel, IContent? lastUserPrompt, ChatThread? chatThread, CancellationToken token = default) public async Task<ChatThread> CreateFromProviderAsync(IProvider provider, Model chatModel, IContent? lastUserPrompt, ChatThread? chatThread, CancellationToken token = default)
{ {
@ -177,7 +220,8 @@ public sealed class ContentText : IContent
finally finally
{ {
this.Text = this.Text.RemoveThinkTags().Trim(); this.Text = this.Text.RemoveThinkTags().Trim();
this.EndToolRun();
// Inform the UI that the streaming is done: // Inform the UI that the streaming is done:
await this.StreamingDone(); await this.StreamingDone();
} }

View File

@ -1,3 +1,7 @@
using System.Text.Json;
using AIStudio.Tools.ToolCallingSystem;
namespace AIStudio.Chat; namespace AIStudio.Chat;
/// <summary> /// <summary>
@ -6,9 +10,15 @@ namespace AIStudio.Chat;
/// <remarks> /// <remarks>
/// Collected here rather than while counting, so that what counts towards a token budget is one /// Collected here rather than while counting, so that what counts towards a token budget is one
/// question with one answer which a test can ask. It follows what the message builder actually /// question with one answer which a test can ask. It follows what the message builder actually
/// sends: the system prompt, the text of every block, and the attachments hanging off those /// sends: the system prompt, the schema of every tool the model may call, the text of every block,
/// blocks -- plus whatever is standing in the composer but has not been sent yet, because that is /// and the attachments hanging off those blocks -- plus whatever is standing in the composer but
/// the part a person is deciding about while they look at the number. /// has not been sent yet, because that is the part a person is deciding about while they look at
/// the number.
///
/// And, while a request is running, what its tools have returned so far. That is the one part
/// which is not about the next request but about the one in flight: it is what the model is
/// reading at this moment, it is what fills the window while somebody watches, and it is gone
/// again once the answer stands.
/// </remarks> /// </remarks>
public sealed record ConversationParts public sealed record ConversationParts
{ {
@ -23,13 +33,17 @@ public sealed record ConversationParts
public IReadOnlyList<string> Texts { get; init; } = []; public IReadOnlyList<string> Texts { get; init; } = [];
/// <summary> /// <summary>
/// The texts which are still being written. /// The texts which belong to this moment alone.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// They cost exactly what the others cost; what sets them apart is that they will never be seen /// They cost exactly what the others cost; what sets them apart is that they will never be seen
/// again in this shape. The sentence somebody is typing changes with the next pause, and an /// again in this shape. The sentence somebody is typing changes with the next pause, and an
/// answer being streamed is a different text three seconds later -- so remembering what they /// answer being streamed is a different text three seconds later -- so remembering what they
/// cost fills memory with answers nobody will ask for again. /// cost fills memory with answers nobody will ask for again.
///
/// What a model's tools have returned so far belongs here for the same reason, although nobody
/// is writing it: it travels with every further round of one request and with nothing after
/// that, so it is measured while it matters and forgotten when the answer is there.
/// </remarks> /// </remarks>
public IReadOnlyList<string> GrowingTexts { get; init; } = []; public IReadOnlyList<string> GrowingTexts { get; init; } = [];
@ -48,7 +62,9 @@ public sealed record ConversationParts
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Blocks without text are skipped, because the message builder skips them too: a block whose /// Blocks without text are skipped, because the message builder skips them too: a block whose
/// text is empty never becomes a message, whatever else hangs off it. /// text is empty never becomes a message, whatever else hangs off it. What such a block may
/// still carry is the tool conversation of a request which is running right now -- that one
/// does travel, and it is read before the text is looked at.
/// </remarks> /// </remarks>
/// <param name="thread">The conversation so far, or null when there is none yet.</param> /// <param name="thread">The conversation so far, or null when there is none yet.</param>
/// <param name="systemPrompt"> /// <param name="systemPrompt">
@ -59,8 +75,12 @@ public sealed record ConversationParts
/// <param name="draft">What stands in the composer.</param> /// <param name="draft">What stands in the composer.</param>
/// <param name="draftAttachments">What is attached to the composer.</param> /// <param name="draftAttachments">What is attached to the composer.</param>
/// <param name="imagesAreSent">Whether the model takes images at all. When it does not, none are sent.</param> /// <param name="imagesAreSent">Whether the model takes images at all. When it does not, none are sent.</param>
/// <param name="toolDefinitions">
/// The tools the model may call, filtered for the provider the same way they are before
/// sending, or null when there are none.
/// </param>
/// <returns>The parts of the conversation.</returns> /// <returns>The parts of the conversation.</returns>
public static ConversationParts Of(ChatThread? thread, string systemPrompt, string draft, IEnumerable<FileAttachment>? draftAttachments, bool imagesAreSent) public static ConversationParts Of(ChatThread? thread, string systemPrompt, string draft, IEnumerable<FileAttachment>? draftAttachments, bool imagesAreSent, IEnumerable<ToolDefinition>? toolDefinitions)
{ {
var texts = new List<string>(); var texts = new List<string>();
var growing = new List<string>(); var growing = new List<string>();
@ -70,6 +90,15 @@ public sealed record ConversationParts
if (!string.IsNullOrWhiteSpace(systemPrompt)) if (!string.IsNullOrWhiteSpace(systemPrompt))
texts.Add(systemPrompt); texts.Add(systemPrompt);
//
// The tools ride along beside the messages, one schema each, in every single request of a
// conversation. Counted with the lasting texts rather than with the growing ones: a schema
// is the same string all session long, so measuring it once and remembering it is exactly
// what the cache is for.
//
foreach (var definition in toolDefinitions ?? [])
texts.Add(Describe(definition));
if (thread is not null) if (thread is not null)
{ {
// //
@ -79,7 +108,18 @@ public sealed record ConversationParts
// //
foreach (var block in thread.Blocks) foreach (var block in thread.Blocks)
{ {
if (block.ContentType is not ContentType.TEXT || block.Content is not ContentText text || string.IsNullOrWhiteSpace(text.Text)) if (block.ContentType is not ContentType.TEXT || block.Content is not ContentText text)
continue;
//
// Asked before the text is, because while a model calls tools there is no text yet:
// the answer arrives in one piece at the end, and everything in between travels as
// the tool conversation. A block skipped for having nothing to say is exactly the
// block whose request is growing the fastest.
//
growing.AddRange(text.PendingToolConversation);
if (string.IsNullOrWhiteSpace(text.Text))
continue; continue;
if (text.IsStreaming) if (text.IsStreaming)
@ -106,6 +146,27 @@ public sealed record ConversationParts
}; };
} }
/// <summary>
/// What one tool costs the request it is offered in.
/// </summary>
/// <remarks>
/// Its name, what it tells the model it does, and the arguments it takes -- that is what the
/// provider adapters put into the tool list of the request body. The wire shape differs
/// between the APIs: they name the fields differently, and a strict schema is rewritten for
/// the OpenAI ones. None of that changes the length by an amount which matters next to a
/// conversation, and the number is reported as an estimate anyway.
/// </remarks>
/// <param name="definition">The tool as it was declared.</param>
/// <returns>The text to count for it.</returns>
private static string Describe(ToolDefinition definition)
{
var parameters = definition.Function.Parameters.ValueKind is JsonValueKind.Undefined
? string.Empty
: definition.Function.Parameters.GetRawText();
return $"{definition.Function.Name}{definition.Function.DescriptionForLLM}{parameters}";
}
/// <summary> /// <summary>
/// Puts attachments into the two groups they are counted in. /// Puts attachments into the two groups they are counted in.
/// </summary> /// </summary>

View File

@ -1475,8 +1475,9 @@ public partial class ChatComponent : MSGComponentBase
// of it would tell a person their window is empty while their first message is not. // of it would tell a person their window is empty while their first message is not.
// //
var thread = this.ChatThread ?? this.NewChatThread(string.Empty); var thread = this.ChatThread ?? this.NewChatThread(string.Empty);
var toolDefinitions = this.GetRunnableToolDefinitions();
provider = this.Provider; provider = this.Provider;
parts = ConversationParts.Of(thread, this.BuildSystemPromptFor(thread), this.UserInput, this.ComposerState.FileAttachments, provider.SupportsImageInput()); parts = ConversationParts.Of(thread, this.BuildSystemPromptFor(thread, toolDefinitions), this.UserInput, this.ComposerState.FileAttachments, provider.SupportsImageInput(), toolDefinitions);
}); });
var counted = await this.ConversationTokenCounter.CountAsync(provider, parts, token); var counted = await this.ConversationTokenCounter.CountAsync(provider, parts, token);
@ -1501,22 +1502,29 @@ public partial class ChatComponent : MSGComponentBase
/// source is appended to it, the selected profile adds a paragraph, and the policy of the /// source is appended to it, the selected profile adds a paragraph, and the policy of the
/// selected tools adds another. Switching a profile while writing therefore moves the number, /// selected tools adds another. Switching a profile while writing therefore moves the number,
/// which is the whole reason this is asked rather than read off the thread. /// which is the whole reason this is asked rather than read off the thread.
///
/// The tools are filtered for the provider the same way they are before sending, so that a tool
/// the provider is not trusted enough to receive does not count either.
/// </remarks> /// </remarks>
/// <param name="thread">The thread to build the prompt for.</param> /// <param name="thread">The thread to build the prompt for.</param>
/// <param name="toolDefinitions">The tools whose policy the prompt states.</param>
/// <returns>The system prompt as it would be sent.</returns> /// <returns>The system prompt as it would be sent.</returns>
private string BuildSystemPromptFor(ChatThread thread) private string BuildSystemPromptFor(ChatThread thread, IReadOnlyList<ToolDefinition> toolDefinitions) => thread.BuildSystemPrompt(this.SettingsManager, toolDefinitions).Text;
{
var toolDefinitions = this.ToolRegistry.FilterToolIdsForProvider(this.Provider, this.selectedToolIds)
.Select(this.ToolRegistry.GetDefinition)
.Where(definition => definition is not null)
.Select(definition => definition!)
.ToList();
return thread.BuildSystemPrompt(this.SettingsManager, toolDefinitions).Text; /// <summary>
} /// The tools the next request would offer the model.
/// </summary>
/// <remarks>
/// Filtered for the provider the same way they are before sending, so that a tool the provider
/// is not trusted enough to receive does not count either.
///
/// Asked for once and used twice: their policy goes into the system prompt, and their schemas
/// travel next to it in the request body. Both cost tokens, and both change the moment somebody
/// switches a tool on.
/// </remarks>
/// <returns>The definitions of the selected tools.</returns>
private IReadOnlyList<ToolDefinition> GetRunnableToolDefinitions() => this.ToolRegistry.FilterToolIdsForProvider(this.Provider, this.selectedToolIds)
.Select(this.ToolRegistry.GetDefinition)
.Where(definition => definition is not null)
.Select(definition => definition!)
.ToList();
/// <summary> /// <summary>
/// The thread a new chat starts with, as the selections made so far decide it. /// The thread a new chat starts with, as the selections made so far decide it.

View File

@ -19,9 +19,13 @@ public sealed class AnthropicToolCallingAdapter(Model chatModel, IList<IMessageB
{ {
private readonly List<IMessageBase> internalMessages = []; private readonly List<IMessageBase> internalMessages = [];
private readonly List<AnthropicToolResultContent> pendingToolResults = []; private readonly List<AnthropicToolResultContent> pendingToolResults = [];
private readonly List<string> recordedRequestTexts = [];
private readonly List<AnthropicTool> tools = runnableTools.Select(x => ProviderToolAdapters.ToAnthropicTool(x.Definition)).ToList(); private readonly List<AnthropicTool> tools = runnableTools.Select(x => ProviderToolAdapters.ToAnthropicTool(x.Definition)).ToList();
private AnthropicResponse? lastResponse; private AnthropicResponse? lastResponse;
/// <inheritdoc />
public IReadOnlyList<string> RecordedRequestTexts => this.recordedRequestTexts;
/// <inheritdoc /> /// <inheritdoc />
public async Task<ToolCallingRound?> ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, CancellationToken token = default) public async Task<ToolCallingRound?> ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, CancellationToken token = default)
{ {
@ -76,13 +80,30 @@ public sealed class AnthropicToolCallingAdapter(Model chatModel, IList<IMessageB
// returned unchanged for the model to continue from them. // returned unchanged for the model to continue from them.
// //
this.internalMessages.Add(new AnthropicMessage([..this.lastResponse.Content])); this.internalMessages.Add(new AnthropicMessage([..this.lastResponse.Content]));
//
// And they are counted exactly as they arrived, for the same reason: a thinking block is
// sent back whole, so what it costs is what it says, not what we could read out of it.
//
foreach (var contentBlock in this.lastResponse.Content)
this.recordedRequestTexts.Add(contentBlock.GetRawText());
} }
/// <inheritdoc /> /// <inheritdoc />
public void RecordToolResult(string callId, string content, bool isError = false) => this.pendingToolResults.Add(new AnthropicToolResultContent public void RecordToolResult(string callId, string content, bool isError = false)
{ {
ToolUseId = callId, this.pendingToolResults.Add(new AnthropicToolResultContent
Content = content, {
IsError = isError, ToolUseId = callId,
}); Content = content,
IsError = isError,
});
//
// Noted here rather than when the results are flushed into their message: the round they
// belong to is over, and whoever asks in the meantime has to see what it cost.
//
if (!string.IsNullOrWhiteSpace(content))
this.recordedRequestTexts.Add(content);
}
} }

View File

@ -81,7 +81,7 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, n
var toolRegistry = Program.SERVICE_PROVIDER.GetService<ToolRegistry>(); var toolRegistry = Program.SERVICE_PROVIDER.GetService<ToolRegistry>();
var toolExecutor = Program.SERVICE_PROVIDER.GetService<ToolExecutor>(); var toolExecutor = Program.SERVICE_PROVIDER.GetService<ToolExecutor>();
var currentAssistantContent = chatThread.Blocks.LastOrDefault(x => x.Role is ChatRole.AI)?.Content as ContentText; var currentAssistantContent = chatThread.Blocks.LastOrDefault(x => x.Role is ChatRole.AI)?.Content as ContentText;
currentAssistantContent?.ToolInvocations.Clear(); currentAssistantContent?.BeginToolRun();
var providerSettings = this.CreateSettingsProvider(chatModel); var providerSettings = this.CreateSettingsProvider(chatModel);
var runnableTools = toolRegistry is null var runnableTools = toolRegistry is null

View File

@ -1270,7 +1270,7 @@ public abstract class BaseProvider : IProvider, ISecretId
var toolRegistry = Program.SERVICE_PROVIDER.GetService<ToolRegistry>(); var toolRegistry = Program.SERVICE_PROVIDER.GetService<ToolRegistry>();
var toolExecutor = Program.SERVICE_PROVIDER.GetService<ToolExecutor>(); var toolExecutor = Program.SERVICE_PROVIDER.GetService<ToolExecutor>();
var currentAssistantContent = chatThread.Blocks.LastOrDefault(x => x.Role is ChatRole.AI)?.Content as ContentText; var currentAssistantContent = chatThread.Blocks.LastOrDefault(x => x.Role is ChatRole.AI)?.Content as ContentText;
currentAssistantContent?.ToolInvocations.Clear(); currentAssistantContent?.BeginToolRun();
TextMessage systemPrompt; TextMessage systemPrompt;
if (toolRegistry is not null && toolExecutor is not null) if (toolRegistry is not null && toolExecutor is not null)

View File

@ -22,9 +22,13 @@ public sealed class ChatCompletionToolCallingAdapter<TRequest>(
: IToolCallingProviderAdapter where TRequest : ChatCompletionAPIRequest : IToolCallingProviderAdapter where TRequest : ChatCompletionAPIRequest
{ {
private readonly List<IMessageBase> internalMessages = []; private readonly List<IMessageBase> internalMessages = [];
private readonly List<string> recordedRequestTexts = [];
private ChatCompletionResponseMessage? lastResponseMessage; private ChatCompletionResponseMessage? lastResponseMessage;
private List<ChatCompletionToolCall> lastToolCalls = []; private List<ChatCompletionToolCall> lastToolCalls = [];
/// <inheritdoc />
public IReadOnlyList<string> RecordedRequestTexts => this.recordedRequestTexts;
/// <inheritdoc /> /// <inheritdoc />
public async Task<ToolCallingRound?> ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, CancellationToken token = default) public async Task<ToolCallingRound?> ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, CancellationToken token = default)
{ {
@ -79,23 +83,55 @@ public sealed class ChatCompletionToolCallingAdapter<TRequest>(
} }
/// <inheritdoc /> /// <inheritdoc />
public void RecordAssistantTurn() => this.internalMessages.Add(new AssistantToolCallMessage public void RecordAssistantTurn()
{ {
Content = this.lastResponseMessage?.RawContent, this.internalMessages.Add(new AssistantToolCallMessage
ReasoningContent = this.lastResponseMessage?.ReasoningContent, {
ToolCalls = this.lastToolCalls, Content = this.lastResponseMessage?.RawContent,
}); ReasoningContent = this.lastResponseMessage?.ReasoningContent,
ToolCalls = this.lastToolCalls,
});
//
// The text of the message, not the message: this adapter builds the message itself, so it
// knows which of its fields carry words rather than wire format. The name of a call travels
// with its arguments because the model is charged for both.
//
this.Record(this.lastResponseMessage?.Content);
this.Record(this.lastResponseMessage?.ReasoningContent);
foreach (var toolCall in this.lastToolCalls)
this.Record($"{toolCall.Function?.Name}{toolCall.Function?.Arguments}");
}
/// <inheritdoc /> /// <inheritdoc />
/// <remarks> /// <remarks>
/// Chat Completions has no error flag on a tool message, so a failure travels in the content /// Chat Completions has no error flag on a tool message, so a failure travels in the content
/// like any other result. /// like any other result.
/// </remarks> /// </remarks>
public void RecordToolResult(string callId, string content, bool isError = false) => this.internalMessages.Add(new ToolResultMessage public void RecordToolResult(string callId, string content, bool isError = false)
{ {
Content = content, this.internalMessages.Add(new ToolResultMessage
ToolCallId = callId, {
}); Content = content,
ToolCallId = callId,
});
this.Record(content);
}
/// <summary>
/// Notes one piece of text as part of what the next round sends.
/// </summary>
/// <remarks>
/// Empty pieces are left out rather than noted as nothing. A round without text and a round
/// without reasoning are the normal case here, and a list of empty strings would be carried
/// through the whole counting for no answer it could change.
/// </remarks>
private void Record(string? text)
{
if (!string.IsNullOrWhiteSpace(text))
this.recordedRequestTexts.Add(text);
}
/// <summary> /// <summary>
/// Normalizes the tool calls of one response. /// Normalizes the tool calls of one response.

View File

@ -176,7 +176,7 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
var toolExecutor = Program.SERVICE_PROVIDER.GetService<ToolExecutor>(); var toolExecutor = Program.SERVICE_PROVIDER.GetService<ToolExecutor>();
var currentAssistantContent = chatThread.Blocks.LastOrDefault(x => x.Role is ChatRole.AI)?.Content as ContentText; var currentAssistantContent = chatThread.Blocks.LastOrDefault(x => x.Role is ChatRole.AI)?.Content as ContentText;
currentAssistantContent?.ToolInvocations.Clear(); currentAssistantContent?.BeginToolRun();
IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools = toolRegistry is null IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools = toolRegistry is null
? [] ? []

View File

@ -16,8 +16,12 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> b
Func<ResponsesAPIRequest, CancellationToken, Task<ResponsesResponse?>> executeRequestAsync) : IToolCallingProviderAdapter Func<ResponsesAPIRequest, CancellationToken, Task<ResponsesResponse?>> executeRequestAsync) : IToolCallingProviderAdapter
{ {
private readonly List<object> internalItems = []; private readonly List<object> internalItems = [];
private readonly List<string> recordedRequestTexts = [];
private ResponsesResponse? lastResponse; private ResponsesResponse? lastResponse;
/// <inheritdoc />
public IReadOnlyList<string> RecordedRequestTexts => this.recordedRequestTexts;
/// <summary> /// <summary>
/// The tools offered to the model: the provider-native ones plus our local functions. /// The tools offered to the model: the provider-native ones plus our local functions.
/// </summary> /// </summary>
@ -77,7 +81,17 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> b
// Every output item, not just the function calls: the API rejects a continuation whose // Every output item, not just the function calls: the API rejects a continuation whose
// reasoning items are missing. // reasoning items are missing.
foreach (var outputItem in this.lastResponse.Output) foreach (var outputItem in this.lastResponse.Output)
{
this.internalItems.Add(outputItem); this.internalItems.Add(outputItem);
//
// The item as it came in, because that is how it goes back out. Reading the text out
// of it would mean knowing every item type the API has, including the ones it gains
// later -- and a reasoning item nobody recognized would then cost nothing here while
// costing its tokens on the wire.
//
this.recordedRequestTexts.Add(outputItem.GetRawText());
}
} }
/// <inheritdoc /> /// <inheritdoc />
@ -85,11 +99,17 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> b
/// The Responses API has no error flag on a function call output, so a failure travels in the /// The Responses API has no error flag on a function call output, so a failure travels in the
/// output like any other result. /// output like any other result.
/// </remarks> /// </remarks>
public void RecordToolResult(string callId, string content, bool isError = false) => this.internalItems.Add(new ResponsesFunctionCallOutputItem public void RecordToolResult(string callId, string content, bool isError = false)
{ {
CallId = callId, this.internalItems.Add(new ResponsesFunctionCallOutputItem
Output = content, {
}); CallId = callId,
Output = content,
});
if (!string.IsNullOrWhiteSpace(content))
this.recordedRequestTexts.Add(content);
}
private static IList<object> BuildEffectiveProviderTools(IList<object> providerTools, IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools) private static IList<object> BuildEffectiveProviderTools(IList<object> providerTools, IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools)
{ {

View File

@ -28,6 +28,15 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
public DateTimeOffset LastCheckpoint { get; set; } public DateTimeOffset LastCheckpoint { get; set; }
/// <summary>
/// When the chat was last told that something happened which was not a streamed chunk.
/// </summary>
/// <remarks>
/// Kept on the job rather than in the loop which streams, because the tool calling reports
/// from outside that loop: it runs inside the provider call the loop is waiting on.
/// </remarks>
public DateTimeOffset LastActivityNotification { get; set; }
public bool IsCompletionStarted { get; set; } public bool IsCompletionStarted { get; set; }
public readonly Lock SyncRoot = new(); public readonly Lock SyncRoot = new();
@ -79,6 +88,44 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
return this.jobs.TryGetValue(jobId, out var job) ? job.ChatGenerationRequest?.ChatThread : null; return this.jobs.TryGetValue(jobId, out var job) ? job.ChatGenerationRequest?.ChatThread : null;
} }
/// <summary>
/// Says that the answer of a chat has moved without a chunk having arrived.
/// </summary>
/// <remarks>
/// A model which calls tools asks several times before it says anything, and while it does,
/// this service sits in the provider call and hands nothing to the screen. But the request is
/// growing the whole time -- every tool result travels with the next round -- and the chat is
/// what recounts the tokens when it renders. Without this, the only thing which would ever ask
/// again is the ten-second heartbeat of the token tracker.
///
/// Throttled like the streamed chunks, and by the same setting: a round which calls five tools
/// in a row must not turn into five renders of the whole chat when somebody asked us to go easy
/// on their battery.
///
/// A chat without a running job is not an error. The same tool calling loop runs for the
/// assistants, which have no job behind them and no token count to update.
/// </remarks>
/// <param name="chatId">The chat whose answer moved.</param>
public async Task NotifyChatActivityAsync(Guid chatId)
{
if (!this.activeChatJobsByChatId.TryGetValue(chatId, out var jobId))
return;
if (!this.jobs.TryGetValue(jobId, out var job))
return;
lock (job.SyncRoot)
{
var now = DateTimeOffset.Now;
if (settingsManager.ConfigurationData.App.IsSavingEnergy && now - job.LastActivityNotification < STREAMING_EVENT_MIN_TIME)
return;
job.LastActivityNotification = now;
}
await this.NotifyChangedAsync(job);
}
public async Task<AIJobSnapshot?> TryStartChatGenerationAsync(ChatGenerationRequest request) public async Task<AIJobSnapshot?> TryStartChatGenerationAsync(ChatGenerationRequest request)
{ {
if (this.activeChatJobsByChatId.TryGetValue(request.ChatThread.ChatId, out var existingJobId)) if (this.activeChatJobsByChatId.TryGetValue(request.ChatThread.ChatId, out var existingJobId))
@ -309,6 +356,7 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
aiText.InitialRemoteWait = false; aiText.InitialRemoteWait = false;
aiText.IsStreaming = false; aiText.IsStreaming = false;
aiText.Text = aiText.Text.RemoveThinkTags().Trim(); aiText.Text = aiText.Text.RemoveThinkTags().Trim();
aiText.EndToolRun();
RemoveEmptyAIResponse(state); RemoveEmptyAIResponse(state);

View File

@ -49,4 +49,19 @@ public interface IToolCallingProviderAdapter
/// the others carry the failure in the content, which is where it has to be legible anyway. /// the others carry the failure in the content, which is where it has to be legible anyway.
/// </param> /// </param>
public void RecordToolResult(string callId, string content, bool isError = false); public void RecordToolResult(string callId, string content, bool isError = false);
/// <summary>
/// The texts which everything recorded so far adds to the request of every following round.
/// </summary>
/// <remarks>
/// Kept by the adapter rather than by the loop, because the adapter is the only place which
/// knows what actually travels. The loop hands over arguments and results and would count
/// those; what the Responses API additionally demands back -- its reasoning items -- never
/// passes through the loop at all, and a conversation whose largest part is invisible is the
/// very thing this is here to rule out.<br/><br/>
/// These texts exist for as long as the adapter does, which is one streaming call. Nothing of
/// this reaches the next request the user sends: the accumulated conversation goes away with
/// the adapter.
/// </remarks>
public IReadOnlyList<string> RecordedRequestTexts { get; }
} }

View File

@ -114,6 +114,7 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
// The model's turn has to be recorded before its results, or the provider sees // The model's turn has to be recorded before its results, or the provider sees
// results for a turn it does not know about: // results for a turn it does not know about:
adapter.RecordAssistantTurn(); adapter.RecordAssistantTurn();
await context.PublishPendingToolConversationAsync(adapter);
foreach (var call in round.Calls) foreach (var call in round.Calls)
{ {
@ -124,6 +125,7 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
toolResultCharacterCount += invalidContent.Length; toolResultCharacterCount += invalidContent.Length;
await context.AddToolInvocationAsync(invalidTrace); await context.AddToolInvocationAsync(invalidTrace);
adapter.RecordToolResult(call.CallId, invalidContent, isError: true); adapter.RecordToolResult(call.CallId, invalidContent, isError: true);
await context.PublishPendingToolConversationAsync(adapter);
continue; continue;
} }
@ -135,6 +137,7 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
if (callsUnavailableInstruction is not null) if (callsUnavailableInstruction is not null)
{ {
adapter.RecordToolResult(call.CallId, callsUnavailableInstruction); adapter.RecordToolResult(call.CallId, callsUnavailableInstruction);
await context.PublishPendingToolConversationAsync(adapter);
continue; continue;
} }
@ -156,6 +159,7 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
// A blocked call counts as a failure towards the model as much as an errored // A blocked call counts as a failure towards the model as much as an errored
// one does: in both cases it did not get the data it asked for. // one does: in both cases it did not get the data it asked for.
adapter.RecordToolResult(call.CallId, toolContent, trace.Status is not ToolInvocationTraceStatus.SUCCESS); adapter.RecordToolResult(call.CallId, toolContent, trace.Status is not ToolInvocationTraceStatus.SUCCESS);
await context.PublishPendingToolConversationAsync(adapter);
} }
} }
finally finally

View File

@ -1,5 +1,6 @@
using AIStudio.Chat; using AIStudio.Chat;
using AIStudio.Provider; using AIStudio.Provider;
using AIStudio.Tools.AIJobs;
namespace AIStudio.Tools.ToolCallingSystem.Harness; namespace AIStudio.Tools.ToolCallingSystem.Harness;
@ -55,7 +56,25 @@ public sealed class ToolCallingLoopContext
return; return;
this.CurrentAssistantContent.ToolInvocations.Add(trace); this.CurrentAssistantContent.ToolInvocations.Add(trace);
await this.CurrentAssistantContent.StreamingEvent(); await this.AnnounceAsync(this.CurrentAssistantContent);
}
/// <summary>
/// Hands the conversation the adapter has accumulated to the assistant message.
/// </summary>
/// <remarks>
/// Called after every recording, not once per round: a round which reads five web pages is the
/// one during which the request grows the most, and a number which only moves between rounds
/// would stand still through exactly that.
/// </remarks>
/// <param name="adapter">The adapter of this run, which knows what it has recorded.</param>
public async Task PublishPendingToolConversationAsync(IToolCallingProviderAdapter adapter)
{
if (this.CurrentAssistantContent is null)
return;
this.CurrentAssistantContent.PendingToolConversation = [..adapter.RecordedRequestTexts];
await this.AnnounceAsync(this.CurrentAssistantContent);
} }
/// <summary> /// <summary>
@ -72,7 +91,7 @@ public sealed class ToolCallingLoopContext
ToolNames = [.. toolNames], ToolNames = [.. toolNames],
}; };
await this.CurrentAssistantContent.StreamingEvent(); await this.AnnounceAsync(this.CurrentAssistantContent);
} }
/// <summary> /// <summary>
@ -88,6 +107,32 @@ public sealed class ToolCallingLoopContext
return; return;
this.CurrentAssistantContent.ToolRuntimeStatus = new(); this.CurrentAssistantContent.ToolRuntimeStatus = new();
await this.CurrentAssistantContent.StreamingEvent(); await this.AnnounceAsync(this.CurrentAssistantContent);
}
/// <summary>
/// Says that something about the running answer has changed.
/// </summary>
/// <remarks>
/// Two receivers, because the screen is built from two of them. The content's own event
/// renders the message block, which is what shows a running tool and the calls it has made.
/// The job service renders the chat around it, and that is what recounts the tokens -- which
/// nothing else would ask for during a tool run: the chat hears about progress one streamed
/// chunk at a time, and a tool run produces none until it is over.<br/><br/>
/// One method rather than two calls at each of the four places above, because the second of
/// them is the one which is easy to forget.
/// </remarks>
/// <param name="content">The assistant message which changed.</param>
private async Task AnnounceAsync(ContentText content)
{
await content.StreamingEvent();
//
// Asked for here rather than taken as a dependency: the same loop runs for the assistants,
// where there is no job to tell and nothing which counts tokens.
//
var jobService = Program.SERVICE_PROVIDER.GetService<AIJobService>();
if (jobService is not null)
await jobService.NotifyChatActivityAsync(this.ChatThread.ChatId);
} }
} }

View File

@ -11,7 +11,7 @@
- Added support for OpenAI's GPT-6 Astra. - Added support for OpenAI's GPT-6 Astra.
- Added the context window to what AI Studio knows about a model, wherever its metadata states one. - Added the context window to what AI Studio knows about a model, wherever its metadata states one.
- Added a live read of that context window at the providers which report it, among them Mistral, Groq, OpenRouter, and self-hosted vLLM servers. You then get the window your own server was started with, not the one the model card advertises. - Added a live read of that context window at the providers which report it, among them Mistral, Groq, OpenRouter, and self-hosted vLLM servers. You then get the window your own server was started with, not the one the model card advertises.
- Added a token count below the message field, so you always see how much of the conversation you have used. It can be an estimate when a provider does not give AI Studio everything it needs to count exactly. - Added a token count below the message field, so you always see how much of the conversation you have used. It counts everything that travels along: your messages, the files you attached, what your data sources contributed, and the tools you offered the AI. It can be an estimate when a provider does not give AI Studio everything it needs to count exactly.
- Added a warning when your conversation holds more images than the model accepts, wherever we know that limit. The Visual Briefing assistant stops before anything is uploaded, instead of letting the provider refuse it afterward. - Added a warning when your conversation holds more images than the model accepts, wherever we know that limit. The Visual Briefing assistant stops before anything is uploaded, instead of letting the provider refuse it afterward.
- Added the context window and the image limits to the expert provider settings, next to the abilities you could already state there. Leave a field empty, and AI Studio keeps its own answer, which you see as the placeholder. IT departments can state the same numbers for the providers they roll out. - Added the context window and the image limits to the expert provider settings, next to the abilities you could already state there. Leave a field empty, and AI Studio keeps its own answer, which you see as the placeholder. IT departments can state the same numbers for the providers they roll out.
- Added model plugins, so IT departments can describe the models their organization runs itself. - Added model plugins, so IT departments can describe the models their organization runs itself.

View File

@ -1,4 +1,7 @@
using System.Text.Json;
using AIStudio.Chat; using AIStudio.Chat;
using AIStudio.Tools.ToolCallingSystem;
namespace AIStudio.Tests.Chat; namespace AIStudio.Tests.Chat;
@ -11,6 +14,10 @@ namespace AIStudio.Tests.Chat;
/// travels with it. So what is collected here has to be what the message builder actually sends -- /// travels with it. So what is collected here has to be what the message builder actually sends --
/// no more, because a number which counts something that stays behind is wrong in the direction /// no more, because a number which counts something that stays behind is wrong in the direction
/// that makes a person stop writing. /// that makes a person stop writing.
///
/// Beyond the messages, a request carries the schema of every tool the model may call, and, while
/// it runs, everything those tools have returned so far. Both are invisible on the screen, and the
/// second one is where a window fills up fastest.
/// </remarks> /// </remarks>
[TestFixture] [TestFixture]
public sealed class ConversationPartsTests public sealed class ConversationPartsTests
@ -44,7 +51,7 @@ public sealed class ConversationPartsTests
], ],
}; };
var parts = ConversationParts.Of(thread, "You are helpful.", "And of Italy?", null, imagesAreSent: true); var parts = ConversationParts.Of(thread, "You are helpful.", "And of Italy?", null, imagesAreSent: true, toolDefinitions: null);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@ -65,7 +72,7 @@ public sealed class ConversationPartsTests
((ContentText)streaming.Content!).IsStreaming = true; ((ContentText)streaming.Content!).IsStreaming = true;
var thread = new ChatThread { Blocks = [Block("A question."), streaming] }; var thread = new ChatThread { Blocks = [Block("A question."), streaming] };
var parts = ConversationParts.Of(thread, string.Empty, "a draft", null, imagesAreSent: true); var parts = ConversationParts.Of(thread, string.Empty, "a draft", null, imagesAreSent: true, toolDefinitions: null);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@ -80,7 +87,7 @@ public sealed class ConversationPartsTests
var finished = Block("The whole answer."); var finished = Block("The whole answer.");
((ContentText)finished.Content!).IsStreaming = false; ((ContentText)finished.Content!).IsStreaming = false;
var parts = ConversationParts.Of(new() { Blocks = [finished] }, string.Empty, string.Empty, null, imagesAreSent: true); var parts = ConversationParts.Of(new() { Blocks = [finished] }, string.Empty, string.Empty, null, imagesAreSent: true, toolDefinitions: null);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@ -100,7 +107,7 @@ public sealed class ConversationPartsTests
// //
var thread = new ChatThread { SystemPrompt = "What the person typed." }; var thread = new ChatThread { SystemPrompt = "What the person typed." };
var parts = ConversationParts.Of(thread, "What the request carries.", string.Empty, null, imagesAreSent: true); var parts = ConversationParts.Of(thread, "What the request carries.", string.Empty, null, imagesAreSent: true, toolDefinitions: null);
Assert.That(parts.Texts, Is.EqualTo(new[] { "What the request carries." })); Assert.That(parts.Texts, Is.EqualTo(new[] { "What the request carries." }));
} }
@ -115,7 +122,7 @@ public sealed class ConversationPartsTests
var hidden = Block("An instruction the user does not see."); var hidden = Block("An instruction the user does not see.");
var thread = new ChatThread { Blocks = [new() { ContentType = hidden.ContentType, Role = hidden.Role, Content = hidden.Content, HideFromUser = true }] }; var thread = new ChatThread { Blocks = [new() { ContentType = hidden.ContentType, Role = hidden.Role, Content = hidden.Content, HideFromUser = true }] };
var parts = ConversationParts.Of(thread, string.Empty, string.Empty, null, imagesAreSent: true); var parts = ConversationParts.Of(thread, string.Empty, string.Empty, null, imagesAreSent: true, toolDefinitions: null);
Assert.That(parts.Texts, Is.EqualTo(new[] { "An instruction the user does not see." })); Assert.That(parts.Texts, Is.EqualTo(new[] { "An instruction the user does not see." }));
} }
@ -123,7 +130,7 @@ public sealed class ConversationPartsTests
[Test] [Test]
public void WithoutAConversationOnlyTheDraftCounts() public void WithoutAConversationOnlyTheDraftCounts()
{ {
var parts = ConversationParts.Of(null, string.Empty, "Hello", null, imagesAreSent: true); var parts = ConversationParts.Of(null, string.Empty, "Hello", null, imagesAreSent: true, toolDefinitions: null);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@ -136,7 +143,7 @@ public sealed class ConversationPartsTests
[TestCase(" ")] [TestCase(" ")]
public void NothingWrittenIsNothingToCount(string draft) public void NothingWrittenIsNothingToCount(string draft)
{ {
var parts = ConversationParts.Of(null, string.Empty, draft, null, imagesAreSent: true); var parts = ConversationParts.Of(null, string.Empty, draft, null, imagesAreSent: true, toolDefinitions: null);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@ -157,7 +164,7 @@ public sealed class ConversationPartsTests
var empty = Block(string.Empty); var empty = Block(string.Empty);
((ContentText)empty.Content!).FileAttachments.Add(FileAttachment.FromPath(document)); ((ContentText)empty.Content!).FileAttachments.Add(FileAttachment.FromPath(document));
var parts = ConversationParts.Of(new() { Blocks = [empty] }, string.Empty, string.Empty, null, imagesAreSent: true); var parts = ConversationParts.Of(new() { Blocks = [empty] }, string.Empty, string.Empty, null, imagesAreSent: true, toolDefinitions: null);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@ -179,7 +186,7 @@ public sealed class ConversationPartsTests
var block = Block("Please read this."); var block = Block("Please read this.");
((ContentText)block.Content!).FileAttachments.Add(FileAttachment.FromPath(older)); ((ContentText)block.Content!).FileAttachments.Add(FileAttachment.FromPath(older));
var parts = ConversationParts.Of(new() { Blocks = [block] }, string.Empty, "And this one.", [FileAttachment.FromPath(draft)], imagesAreSent: true); var parts = ConversationParts.Of(new() { Blocks = [block] }, string.Empty, "And this one.", [FileAttachment.FromPath(draft)], imagesAreSent: true, toolDefinitions: null);
Assert.That(parts.Documents.Select(document => document.FileName), Is.EqualTo(new[] { "older.txt", "draft.txt" })); Assert.That(parts.Documents.Select(document => document.FileName), Is.EqualTo(new[] { "older.txt", "draft.txt" }));
} }
@ -192,7 +199,7 @@ public sealed class ConversationPartsTests
// //
var attachment = FileAttachment.FromPath(Path.Combine(this.directory, "never-existed.txt")); var attachment = FileAttachment.FromPath(Path.Combine(this.directory, "never-existed.txt"));
var parts = ConversationParts.Of(null, string.Empty, "Here", [attachment], imagesAreSent: true); var parts = ConversationParts.Of(null, string.Empty, "Here", [attachment], imagesAreSent: true, toolDefinitions: null);
Assert.That(parts.Documents, Is.Empty); Assert.That(parts.Documents, Is.Empty);
} }
@ -203,7 +210,7 @@ public sealed class ConversationPartsTests
var document = this.WriteFile("notes.txt", "content"); var document = this.WriteFile("notes.txt", "content");
var image = this.WriteFile("photo.png", "not really a png"); var image = this.WriteFile("photo.png", "not really a png");
var parts = ConversationParts.Of(null, string.Empty, "Look", [FileAttachment.FromPath(document), FileAttachment.FromPath(image)], imagesAreSent: true); var parts = ConversationParts.Of(null, string.Empty, "Look", [FileAttachment.FromPath(document), FileAttachment.FromPath(image)], imagesAreSent: true, toolDefinitions: null);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@ -221,11 +228,123 @@ public sealed class ConversationPartsTests
// //
var image = this.WriteFile("photo.png", "not really a png"); var image = this.WriteFile("photo.png", "not really a png");
var parts = ConversationParts.Of(null, string.Empty, "Look", [FileAttachment.FromPath(image)], imagesAreSent: false); var parts = ConversationParts.Of(null, string.Empty, "Look", [FileAttachment.FromPath(image)], imagesAreSent: false, toolDefinitions: null);
Assert.That(parts.Images, Is.Zero); Assert.That(parts.Images, Is.Zero);
} }
[Test]
public void ABlockWithoutTextCountsWhileItsToolsAreStillRunning()
{
//
// While a model calls tools there is no text yet: the answer arrives in one piece at the
// end, and everything in between travels with every further round of the same request. The
// block which looks emptiest is therefore the one whose request is growing the fastest --
// and the one which used to be skipped for having nothing to say.
//
var running = Block(string.Empty);
((ContentText)running.Content!).PendingToolConversation = ["What the web search found.", "What the page said."];
var parts = ConversationParts.Of(new() { Blocks = [running] }, string.Empty, string.Empty, null, imagesAreSent: true, toolDefinitions: null);
Assert.Multiple(() =>
{
Assert.That(parts.Texts, Is.Empty);
Assert.That(parts.GrowingTexts, Is.EqualTo(new[] { "What the web search found.", "What the page said." }));
});
}
[Test]
public void TwoToolResultsWhichReadTheSameCostTwice()
{
//
// The request carries both, so both are paid for. Folding them into one would promise a
// smaller request than the one which is sent -- and a model reading the same page twice is
// not a rare accident but a thing that happens on any busy search.
//
var running = Block(string.Empty);
((ContentText)running.Content!).PendingToolConversation = ["The same page.", "The same page."];
var parts = ConversationParts.Of(new() { Blocks = [running] }, string.Empty, string.Empty, null, imagesAreSent: true, toolDefinitions: null);
Assert.That(parts.GrowingTexts, Is.EqualTo(new[] { "The same page.", "The same page." }));
}
[Test]
public void OnceTheAnswerStandsTheToolConversationIsGone()
{
//
// It travels with the rounds of one request and with nothing afterwards: the next request is
// built from the messages alone. A number which kept counting it would report a window
// fuller than it is, and would never fall back.
//
var answered = Block("Here is what I found.");
var content = (ContentText)answered.Content!;
content.PendingToolConversation = ["What the web search found."];
content.EndToolRun();
var parts = ConversationParts.Of(new() { Blocks = [answered] }, string.Empty, string.Empty, null, imagesAreSent: true, toolDefinitions: null);
Assert.Multiple(() =>
{
Assert.That(parts.Texts, Is.EqualTo(new[] { "Here is what I found." }));
Assert.That(parts.GrowingTexts, Is.Empty);
});
}
[Test]
public void TheToolSchemasCountAndTheyCountWithWhatStands()
{
//
// Every request carries the schema of every offered tool, whether or not the model calls a
// single one of them. They belong with the lasting texts: a schema is the same string all
// session long, so its count is worth remembering.
//
var parts = ConversationParts.Of(null, string.Empty, string.Empty, null, imagesAreSent: true, toolDefinitions:
[
Tool("web_search", "Searches the web.", """{"type":"object"}"""),
Tool("read_web_page", "Reads one page.", """{"type":"string"}"""),
]);
Assert.Multiple(() =>
{
Assert.That(parts.Texts, Is.EqualTo(new[]
{
"""web_searchSearches the web.{"type":"object"}""",
"""read_web_pageReads one page.{"type":"string"}""",
}));
Assert.That(parts.GrowingTexts, Is.Empty);
});
}
[Test]
public void AToolWhichStatesNoArgumentsCountsLikeAnyOther()
{
//
// A definition which never names a parameter schema leaves an empty JSON element behind,
// and asking such an element for its text throws. A tool arriving from a plugin may well
// say nothing about its arguments, and the number under the input field is not the place
// to find that out.
//
var parts = ConversationParts.Of(null, string.Empty, string.Empty, null, imagesAreSent: true, toolDefinitions:
[
new() { Function = new() { Name = "ping", DescriptionForLLM = "Says hello." } },
]);
Assert.That(parts.Texts, Is.EqualTo(new[] { "pingSays hello." }));
}
private static ToolDefinition Tool(string name, string description, string parameterSchema) => new()
{
Function = new()
{
Name = name,
DescriptionForLLM = description,
Parameters = JsonDocument.Parse(parameterSchema).RootElement.Clone(),
},
};
private static ContentBlock Block(string text) => new() private static ContentBlock Block(string text) => new()
{ {
ContentType = ContentType.TEXT, ContentType = ContentType.TEXT,