diff --git a/app/MindWork AI Studio/Chat/ContentText.cs b/app/MindWork AI Studio/Chat/ContentText.cs
index d86424cb..661dcc95 100644
--- a/app/MindWork AI Studio/Chat/ContentText.cs
+++ b/app/MindWork AI Studio/Chat/ContentText.cs
@@ -55,6 +55,49 @@ public sealed class ContentText : IContent
[JsonIgnore]
public ToolRuntimeStatus ToolRuntimeStatus { get; set; } = new();
+ ///
+ /// What the tool conversation of the running request adds to it, as far as it has got.
+ ///
+ ///
+ /// 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.
+ /// 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.
+ /// 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.
+ ///
+ [JsonIgnore]
+ public IReadOnlyList PendingToolConversation { get; set; } = [];
+
+ ///
+ /// Clears what the previous run of the tools left behind.
+ ///
+ ///
+ /// 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.
+ ///
+ public void BeginToolRun()
+ {
+ this.ToolInvocations.Clear();
+ this.PendingToolConversation = [];
+ }
+
+ ///
+ /// Says that no request is running anymore.
+ ///
+ ///
+ /// 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.
+ ///
+ public void EndToolRun() => this.PendingToolConversation = [];
+
///
public async Task CreateFromProviderAsync(IProvider provider, Model chatModel, IContent? lastUserPrompt, ChatThread? chatThread, CancellationToken token = default)
{
@@ -177,7 +220,8 @@ public sealed class ContentText : IContent
finally
{
this.Text = this.Text.RemoveThinkTags().Trim();
-
+ this.EndToolRun();
+
// Inform the UI that the streaming is done:
await this.StreamingDone();
}
diff --git a/app/MindWork AI Studio/Chat/ConversationParts.cs b/app/MindWork AI Studio/Chat/ConversationParts.cs
index 3c6e8dc1..f3683d26 100644
--- a/app/MindWork AI Studio/Chat/ConversationParts.cs
+++ b/app/MindWork AI Studio/Chat/ConversationParts.cs
@@ -1,3 +1,7 @@
+using System.Text.Json;
+
+using AIStudio.Tools.ToolCallingSystem;
+
namespace AIStudio.Chat;
///
@@ -6,9 +10,15 @@ namespace AIStudio.Chat;
///
/// 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
-/// sends: the system prompt, the text of every block, and the attachments hanging off those
-/// blocks -- plus whatever is standing in the composer but has not been sent yet, because that is
-/// the part a person is deciding about while they look at the number.
+/// sends: the system prompt, the schema of every tool the model may call, the text of every block,
+/// and the attachments hanging off those blocks -- plus whatever is standing in the composer but
+/// 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.
///
public sealed record ConversationParts
{
@@ -23,13 +33,17 @@ public sealed record ConversationParts
public IReadOnlyList Texts { get; init; } = [];
///
- /// The texts which are still being written.
+ /// The texts which belong to this moment alone.
///
///
/// 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
/// answer being streamed is a different text three seconds later -- so remembering what they
/// 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.
///
public IReadOnlyList GrowingTexts { get; init; } = [];
@@ -48,7 +62,9 @@ public sealed record ConversationParts
///
///
/// 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.
///
/// The conversation so far, or null when there is none yet.
///
@@ -59,8 +75,12 @@ public sealed record ConversationParts
/// What stands in the composer.
/// What is attached to the composer.
/// Whether the model takes images at all. When it does not, none are sent.
+ ///
+ /// The tools the model may call, filtered for the provider the same way they are before
+ /// sending, or null when there are none.
+ ///
/// The parts of the conversation.
- public static ConversationParts Of(ChatThread? thread, string systemPrompt, string draft, IEnumerable? draftAttachments, bool imagesAreSent)
+ public static ConversationParts Of(ChatThread? thread, string systemPrompt, string draft, IEnumerable? draftAttachments, bool imagesAreSent, IEnumerable? toolDefinitions)
{
var texts = new List();
var growing = new List();
@@ -70,6 +90,15 @@ public sealed record ConversationParts
if (!string.IsNullOrWhiteSpace(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)
{
//
@@ -79,7 +108,18 @@ public sealed record ConversationParts
//
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;
if (text.IsStreaming)
@@ -106,6 +146,27 @@ public sealed record ConversationParts
};
}
+ ///
+ /// What one tool costs the request it is offered in.
+ ///
+ ///
+ /// 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.
+ ///
+ /// The tool as it was declared.
+ /// The text to count for it.
+ 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}";
+ }
+
///
/// Puts attachments into the two groups they are counted in.
///
diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs
index 9ffe4b5b..a11730ba 100644
--- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs
+++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs
@@ -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.
//
var thread = this.ChatThread ?? this.NewChatThread(string.Empty);
+ var toolDefinitions = this.GetRunnableToolDefinitions();
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);
@@ -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
/// 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.
- ///
- /// 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.
///
/// The thread to build the prompt for.
+ /// The tools whose policy the prompt states.
/// The system prompt as it would be sent.
- private string BuildSystemPromptFor(ChatThread thread)
- {
- var toolDefinitions = this.ToolRegistry.FilterToolIdsForProvider(this.Provider, this.selectedToolIds)
- .Select(this.ToolRegistry.GetDefinition)
- .Where(definition => definition is not null)
- .Select(definition => definition!)
- .ToList();
+ private string BuildSystemPromptFor(ChatThread thread, IReadOnlyList toolDefinitions) => thread.BuildSystemPrompt(this.SettingsManager, toolDefinitions).Text;
- return thread.BuildSystemPrompt(this.SettingsManager, toolDefinitions).Text;
- }
+ ///
+ /// The tools the next request would offer the model.
+ ///
+ ///
+ /// 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.
+ ///
+ /// The definitions of the selected tools.
+ private IReadOnlyList GetRunnableToolDefinitions() => this.ToolRegistry.FilterToolIdsForProvider(this.Provider, this.selectedToolIds)
+ .Select(this.ToolRegistry.GetDefinition)
+ .Where(definition => definition is not null)
+ .Select(definition => definition!)
+ .ToList();
///
/// The thread a new chat starts with, as the selections made so far decide it.
diff --git a/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolCallingAdapter.cs b/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolCallingAdapter.cs
index 9843b362..32be87e3 100644
--- a/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolCallingAdapter.cs
+++ b/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolCallingAdapter.cs
@@ -19,9 +19,13 @@ public sealed class AnthropicToolCallingAdapter(Model chatModel, IList internalMessages = [];
private readonly List pendingToolResults = [];
+ private readonly List recordedRequestTexts = [];
private readonly List tools = runnableTools.Select(x => ProviderToolAdapters.ToAnthropicTool(x.Definition)).ToList();
private AnthropicResponse? lastResponse;
+ ///
+ public IReadOnlyList RecordedRequestTexts => this.recordedRequestTexts;
+
///
public async Task ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, CancellationToken token = default)
{
@@ -76,13 +80,30 @@ public sealed class AnthropicToolCallingAdapter(Model chatModel, IList
- 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,
- Content = content,
- IsError = isError,
- });
+ this.pendingToolResults.Add(new AnthropicToolResultContent
+ {
+ 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);
+ }
}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs b/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs
index cab47ab9..71138df9 100644
--- a/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs
+++ b/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs
@@ -81,7 +81,7 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, n
var toolRegistry = Program.SERVICE_PROVIDER.GetService();
var toolExecutor = Program.SERVICE_PROVIDER.GetService();
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 runnableTools = toolRegistry is null
diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs
index 2c15830c..76541bbb 100644
--- a/app/MindWork AI Studio/Provider/BaseProvider.cs
+++ b/app/MindWork AI Studio/Provider/BaseProvider.cs
@@ -1270,7 +1270,7 @@ public abstract class BaseProvider : IProvider, ISecretId
var toolRegistry = Program.SERVICE_PROVIDER.GetService();
var toolExecutor = Program.SERVICE_PROVIDER.GetService();
var currentAssistantContent = chatThread.Blocks.LastOrDefault(x => x.Role is ChatRole.AI)?.Content as ContentText;
- currentAssistantContent?.ToolInvocations.Clear();
+ currentAssistantContent?.BeginToolRun();
TextMessage systemPrompt;
if (toolRegistry is not null && toolExecutor is not null)
diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs
index 594efc43..5c34b4f0 100644
--- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs
+++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs
@@ -22,9 +22,13 @@ public sealed class ChatCompletionToolCallingAdapter(
: IToolCallingProviderAdapter where TRequest : ChatCompletionAPIRequest
{
private readonly List internalMessages = [];
+ private readonly List recordedRequestTexts = [];
private ChatCompletionResponseMessage? lastResponseMessage;
private List lastToolCalls = [];
+ ///
+ public IReadOnlyList RecordedRequestTexts => this.recordedRequestTexts;
+
///
public async Task ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, CancellationToken token = default)
{
@@ -79,23 +83,55 @@ public sealed class ChatCompletionToolCallingAdapter(
}
///
- public void RecordAssistantTurn() => this.internalMessages.Add(new AssistantToolCallMessage
+ public void RecordAssistantTurn()
{
- Content = this.lastResponseMessage?.RawContent,
- ReasoningContent = this.lastResponseMessage?.ReasoningContent,
- ToolCalls = this.lastToolCalls,
- });
+ this.internalMessages.Add(new AssistantToolCallMessage
+ {
+ 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}");
+ }
///
///
/// Chat Completions has no error flag on a tool message, so a failure travels in the content
/// like any other result.
///
- 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,
- ToolCallId = callId,
- });
+ this.internalMessages.Add(new ToolResultMessage
+ {
+ Content = content,
+ ToolCallId = callId,
+ });
+
+ this.Record(content);
+ }
+
+ ///
+ /// Notes one piece of text as part of what the next round sends.
+ ///
+ ///
+ /// 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.
+ ///
+ private void Record(string? text)
+ {
+ if (!string.IsNullOrWhiteSpace(text))
+ this.recordedRequestTexts.Add(text);
+ }
///
/// Normalizes the tool calls of one response.
diff --git a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs
index 7de4e08f..92c9d959 100644
--- a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs
+++ b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs
@@ -176,7 +176,7 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
var toolExecutor = Program.SERVICE_PROVIDER.GetService();
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
? []
diff --git a/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs
index 01486557..a308ce75 100644
--- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs
+++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs
@@ -16,8 +16,12 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList