From 4bb36c6ddc88457ada0730cbc7181ea3b4f4a1fb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Peer=20Sch=C3=BCtt?=
<20603780+peerschuett@users.noreply.github.com>
Date: Fri, 24 Jul 2026 10:53:55 +0200
Subject: [PATCH] Preparing final commit by fixing a lot of small stuff
---
.../Assistants/I18N/allTexts.lua | 15 ++-
.../Chat/ContentBlockComponent.razor | 13 ++-
.../Settings/SettingsPanelApp.razor | 2 +-
.../Plugins/configuration/plugin.lua | 31 +++---
.../Provider/BaseProvider.cs | 104 +++++++++++++-----
.../OpenAI/ChatCompletionResponseMessage.cs | 2 +-
.../Provider/OpenAI/ChatCompletionToolCall.cs | 6 +-
.../OpenAI/ChatCompletionToolFunction.cs | 4 +-
.../Provider/OpenAI/ProviderOpenAI.cs | 54 +++++++--
.../OpenAI/ResponsesFunctionCallItem.cs | 8 +-
.../Provider/OpenAI/ResponsesResponse.cs | 1 -
.../Tools/SourceExtensions.cs | 26 ++++-
.../ReadWebPageTool.cs | 2 +-
.../SearXNGSearchClient.cs | 27 ++++-
.../Tools/ToolCallingSystem/ToolDefinition.cs | 17 ++-
.../Tools/ToolCallingSystem/ToolExecutor.cs | 38 +++++++
.../Tools/ToolCallingSystem/ToolRegistry.cs | 15 ++-
.../Tools/Web/WebPageRetrievalService.cs | 1 +
.../wwwroot/tool_definitions/web_search.json | 4 +-
19 files changed, 286 insertions(+), 84 deletions(-)
diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
index 9cf460bb..9fb4be09 100644
--- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
+++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
@@ -3211,6 +3211,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1364944735"]
-- Additional root certificates are enabled
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1380446131"] = "Additional root certificates are enabled"
+-- You have selected 1 preview feature.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1384241824"] = "You have selected 1 preview feature."
+
-- Select preview features
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1439783084"] = "Select preview features"
@@ -3286,6 +3289,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2655930524"]
-- Path to a PEM file containing one or more root CA certificates. For Flatpak deployments, this file must be placed in a location that is readable inside the sandbox.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2700836219"] = "Path to a PEM file containing one or more root CA certificates. For Flatpak deployments, this file must be placed in a location that is readable inside the sandbox."
+-- No preview features selected.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2809641588"] = "No preview features selected."
+
-- Enter one host pattern per line. Exact hosts such as data.intra.example.org and one-label wildcards such as *.intra.example.org are supported. Cloud provider endpoints built into AI Studio, such as OpenAI, Google, etc., never use these additional root certificates.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2960110864"] = "Enter one host pattern per line. Exact hosts such as data.intra.example.org and one-label wildcards such as *.intra.example.org are supported. Cloud provider endpoints built into AI Studio, such as OpenAI, Google, etc., never use these additional root certificates."
@@ -3298,6 +3304,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3165555978"]
-- External HTTPS certificates
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T348936513"] = "External HTTPS certificates"
+-- You have selected {0} preview features.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3513450626"] = "You have selected {0} preview features."
+
-- Allowed hosts for additional root certificates
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3562495752"] = "Allowed hosts for additional root certificates"
@@ -9265,15 +9274,15 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS:
-- Load a web page and extract its readable content, links, and page details.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::READWEBPAGETOOL::T3715690061"] = "Load a web page and extract its readable content, links, and page details."
+-- (Optional) Host allowlist for private or VPN web pages. For security reasons, private or VPN web pages aren't allowed to be read by default. Separate host patterns with commas, such as example.de, *.example.de. Allowed private hosts require a High-confidence provider or a provider trusted by your organization's configuration. For allowed HTTPS internal hosts, AI Studio also tries the operating system's default sign-in automatically when the server responds with integrated authentication.
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::READWEBPAGETOOL::T3802894016"] = "(Optional) Host allowlist for private or VPN web pages. For security reasons, private or VPN web pages aren't allowed to be read by default. Separate host patterns with commas, such as example.de, *.example.de. Allowed private hosts require a High-confidence provider or a provider trusted by your organization's configuration. For allowed HTTPS internal hosts, AI Studio also tries the operating system's default sign-in automatically when the server responds with integrated authentication."
+
-- (Optional) HTTP timeout for loading a web page in seconds.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::READWEBPAGETOOL::T4126164830"] = "(Optional) HTTP timeout for loading a web page in seconds."
-- The setting '{0}' must be a positive integer.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::READWEBPAGETOOL::T4199432074"] = "The setting '{0}' must be a positive integer."
--- (Optional) Host allowlist for private or VPN web pages. For security reasons, private or VPN web pages aren't allowed to be read by default. Separate host patterns with commas, such as example.de, *.example.de. Allowed private hosts require a High-confidence provider or a provider trusted by your organization's configuration. For allowed internal hosts, AI Studio also tries the operating system's default sign-in automatically when the server responds with integrated authentication.
-UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::READWEBPAGETOOL::T64466871"] = "(Optional) Host allowlist for private or VPN web pages. For security reasons, private or VPN web pages aren't allowed to be read by default. Separate host patterns with commas, such as example.de, *.example.de. Allowed private hosts require a High-confidence provider or a provider trusted by your organization's configuration. For allowed internal hosts, AI Studio also tries the operating system's default sign-in automatically when the server responds with integrated authentication."
-
-- (Optional) Global truncation limit for extracted characters returned to the model.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::READWEBPAGETOOL::T900659180"] = "(Optional) Global truncation limit for extracted characters returned to the model."
diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor
index 8138a0ff..3f625c41 100644
--- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor
+++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor
@@ -202,12 +202,13 @@
}
- @if (this.Role is ChatRole.AI && !string.IsNullOrWhiteSpace(textContent.ToolRuntimeStatus.Message))
- {
-
- @textContent.ToolRuntimeStatus.Message
-
- }
+ }
+
+ @if (this.Role is ChatRole.AI && !string.IsNullOrWhiteSpace(textContent.ToolRuntimeStatus.Message))
+ {
+
+ @textContent.ToolRuntimeStatus.Message
+
}
}
}
diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor
index cb8ab7b5..34b0f3cd 100644
--- a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor
+++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor
@@ -27,7 +27,7 @@
var availablePreviewFeatures = ConfigurationSelectDataFactory.GetPreviewFeaturesData(this.SettingsManager).ToList();
if (availablePreviewFeatures.Count > 0)
{
-
+
}
}
diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua
index c154eadf..a04e91dd 100644
--- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua
+++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua
@@ -375,36 +375,35 @@ CONFIG["SETTINGS"] = {}
-- }
-- Configure the Web Search tool. All values are strings.
--- WebSearchBaseUrl: required SearXNG HTTP(S) root URL or /search endpoint; no default.
+-- WebSearchBaseUrl: SearXNG HTTP(S) root URL or /search endpoint.
-- CONFIG["SETTINGS"]["DataTools.WebSearchBaseUrl"] = "https://searxng.website/"
--- WebSearchDefaultLanguage: optional language code; default is empty.
+-- WebSearchDefaultLanguage: language code sent to SearXNG.
-- CONFIG["SETTINGS"]["DataTools.WebSearchDefaultLanguage"] = "de"
--- WebSearchDefaultSafeSearch: optional SearXNG safe-search level "0", "1", or "2"; default is empty.
+-- WebSearchDefaultSafeSearch: SearXNG safe-search level "0", "1", or "2".
-- CONFIG["SETTINGS"]["DataTools.WebSearchDefaultSafeSearch"] = "1"
--- WebSearchMaxResults: positive integer; default 5, effective maximum 20.
+-- WebSearchMaxResults: result count as an integer string.
-- CONFIG["SETTINGS"]["DataTools.WebSearchMaxResults"] = "5"
--- WebSearchTimeoutSeconds: positive integer; default 20, effective maximum 60.
+-- WebSearchTimeoutSeconds: SearXNG request timeout in seconds as an integer string.
-- CONFIG["SETTINGS"]["DataTools.WebSearchTimeoutSeconds"] = "20"
--- WebSearchMaxTotalContentCharacters: positive integer; default and maximum 100000.
--- maximum number of content characters per web search
+-- WebSearchMaxTotalContentCharacters: total content-character budget as an integer string.
-- CONFIG["SETTINGS"]["DataTools.WebSearchMaxTotalContentCharacters"] = "100000"
--- WebSearchMinContentCharactersPerResult: positive integer; default and maximum 3000.
--- The total content budget must be at least this value multiplied by the hard limit of 20 results.
+-- WebSearchMinContentCharactersPerResult: per-result content allocation as an integer string.
-- CONFIG["SETTINGS"]["DataTools.WebSearchMinContentCharactersPerResult"] = "3000"
--- WebSearchPageTimeoutSeconds: positive integer; default and maximum 30.
+-- WebSearchPageTimeoutSeconds: per-page timeout in seconds as an integer string.
-- CONFIG["SETTINGS"]["DataTools.WebSearchPageTimeoutSeconds"] = "30"
--- WebSearchRetrievalTimeoutSeconds: positive integer; default and maximum 90.
+-- WebSearchRetrievalTimeoutSeconds: overall page-retrieval timeout in seconds as an integer string.
-- CONFIG["SETTINGS"]["DataTools.WebSearchRetrievalTimeoutSeconds"] = "90"
-- Configure the Read Web Page tool. All values are strings.
--- ReadWebPageTimeoutSeconds: positive integer; default 30, effective maximum 60.
+-- ReadWebPageTimeoutSeconds: page-loading timeout in seconds as an integer string.
-- CONFIG["SETTINGS"]["DataTools.ReadWebPageTimeoutSeconds"] = "30"
--- ReadWebPageMaxContentCharacters: positive integer; default 30000, effective maximum 50000.
+-- ReadWebPageMaxContentCharacters: content-character limit as an integer string.
-- CONFIG["SETTINGS"]["DataTools.ReadWebPageMaxContentCharacters"] = "30000"
--- ReadWebPageAllowedPrivateHosts: optional comma-separated private or VPN host patterns; default is empty.
+-- ReadWebPageAllowedPrivateHosts: comma-separated private or VPN host patterns.
-- Public pages do not need to be listed. Wildcards only match subdomains, so add the root domain separately.
--- Allowed private hosts require a provider with HIGH confidence. AI Studio tries the current user's
--- operating-system sign-in when integrated authentication is requested, but does not reuse browser cookies.
+-- Allowed private hosts require a provider with HIGH confidence or a provider trusted by the organization.
+-- AI Studio only tries the current user's operating-system sign-in for explicitly allowed HTTPS targets
+-- when these provider requirements are met, and it does not reuse browser cookies.
-- CONFIG["SETTINGS"]["DataTools.ReadWebPageAllowedPrivateHosts"] = "dlr.de, *.dlr.de"
-- The 12 Web Search and Read Web Page settings are locked by default. Add
diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs
index 366c0513..0639c28e 100644
--- a/app/MindWork AI Studio/Provider/BaseProvider.cs
+++ b/app/MindWork AI Studio/Provider/BaseProvider.cs
@@ -1064,7 +1064,7 @@ public abstract class BaseProvider : IProvider, ISecretId
yield break;
}
- var toolCalls = this.CanonicalizeToolCallNames(responseMessage.ToolCalls ?? [], runnableTools);
+ var toolCalls = this.PrepareChatCompletionToolCalls(responseMessage.ToolCalls ?? [], runnableTools);
if (toolCalls.Count == 0)
{
await ResetToolRuntimeStatusAsync();
@@ -1089,34 +1089,53 @@ public abstract class BaseProvider : IProvider, ISecretId
try
{
- await ShowToolRuntimeStatusAsync(toolCalls
- .Select(x => runnableTools.FirstOrDefault(tool => tool.Definition.Function.Name.Equals(x.Function.Name, StringComparison.Ordinal)).Implementation?.GetDisplayName() ?? x.Function.Name));
+ var validToolNames = toolCalls
+ .Where(x => x.IsValid)
+ .Select(x => runnableTools.FirstOrDefault(tool => tool.Definition.Function.Name.Equals(x.ToolCall.Function!.Name, StringComparison.Ordinal)).Implementation?.GetDisplayName() ?? x.ToolCall.Function!.Name!)
+ .ToList();
+ if (validToolNames.Count > 0)
+ await ShowToolRuntimeStatusAsync(validToolNames);
internalMessages.Add(new AssistantToolCallMessage
{
Content = responseMessage.RawContent,
ReasoningContent = responseMessage.ReasoningContent,
- ToolCalls = toolCalls,
+ ToolCalls = toolCalls.Select(x => x.ToolCall).ToList(),
});
- foreach (var toolCall in toolCalls)
+ foreach (var preparedToolCall in toolCalls)
{
+ var toolCall = preparedToolCall.ToolCall;
+ if (!preparedToolCall.IsValid)
+ {
+ toolCallCount++;
+ var (invalidToolContent, invalidTrace, _, _) = toolExecutor.CreateInvalidToolCallResult(toolCall.Id!, toolCallCount);
+ toolResultCharacterCount += invalidToolContent.Length;
+ currentAssistantContent?.ToolInvocations.Add(invalidTrace);
+ internalMessages.Add(new ToolResultMessage
+ {
+ Content = invalidToolContent,
+ ToolCallId = toolCall.Id!,
+ });
+ continue;
+ }
+
var toolCallsUnavailableInstruction = ToolSelectionRules.GetToolCallsUnavailableInstruction(toolCallCount, toolResultCharacterCount);
if (toolCallsUnavailableInstruction is not null)
{
internalMessages.Add(new ToolResultMessage
{
Content = toolCallsUnavailableInstruction,
- ToolCallId = toolCall.Id,
+ ToolCallId = toolCall.Id!,
});
continue;
}
toolCallCount++;
var (toolContent, trace, requiredProviderConfidence, sources) = await toolExecutor.ExecuteAsync(
- toolCall.Id,
- toolCall.Function.Name,
- toolCall.Function.Arguments,
+ toolCall.Id!,
+ toolCall.Function!.Name!,
+ toolCall.Function!.Arguments!,
runnableTools,
this,
toolCallCount,
@@ -1129,7 +1148,7 @@ public abstract class BaseProvider : IProvider, ISecretId
internalMessages.Add(new ToolResultMessage
{
Content = toolContent,
- ToolCallId = toolCall.Id,
+ ToolCallId = toolCall.Id!,
});
}
@@ -1182,28 +1201,61 @@ public abstract class BaseProvider : IProvider, ISecretId
InstanceName = this.InstanceName,
};
- private IList CanonicalizeToolCallNames(
- IEnumerable toolCalls,
- IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools) => toolCalls
- .Select(toolCall =>
+ private IList PrepareChatCompletionToolCalls(
+ IEnumerable toolCalls,
+ IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools)
+ {
+ var preparedToolCalls = new List();
+ foreach (var returnedToolCall in toolCalls)
+ {
+ var toolCallId = string.IsNullOrWhiteSpace(returnedToolCall?.Id)
+ ? $"call_{Guid.NewGuid():N}"
+ : returnedToolCall.Id;
+ var returnedFunctionName = returnedToolCall?.Function?.Name;
+ var returnedArguments = returnedToolCall?.Function?.Arguments;
+ var isValid = returnedToolCall?.Function is not null &&
+ !string.IsNullOrWhiteSpace(returnedFunctionName) &&
+ ToolExecutor.IsValidArgumentsJson(returnedArguments);
+ var normalizedToolCall = new ChatCompletionToolCall
{
- var returnedName = toolCall.Function.Name;
- var canonicalName = runnableTools
- .Select(x => x.Definition.Function.Name)
- .FirstOrDefault(x => x.Equals(returnedName.Trim(), StringComparison.Ordinal));
- if (canonicalName is null || canonicalName.Equals(returnedName, StringComparison.Ordinal))
- return toolCall;
-
- this.logger.LogWarning("Canonicalized tool call function name '{ReturnedFunctionName}' to '{CanonicalFunctionName}'.", returnedName, canonicalName);
- return toolCall with
+ Id = toolCallId,
+ Type = string.IsNullOrWhiteSpace(returnedToolCall?.Type) ? "function" : returnedToolCall.Type,
+ Function = new ChatCompletionToolFunction
{
- Function = toolCall.Function with
+ Name = string.IsNullOrWhiteSpace(returnedFunctionName) ? "invalid_tool_call" : returnedFunctionName,
+ Arguments = returnedArguments ?? "{}",
+ },
+ };
+
+ if (!isValid)
+ {
+ this.logger.LogWarning("Received an invalid Chat Completions tool call. ToolCallId={ToolCallId}", toolCallId);
+ preparedToolCalls.Add(new PreparedChatCompletionToolCall(normalizedToolCall, false));
+ continue;
+ }
+
+ var canonicalName = runnableTools
+ .Select(x => x.Definition.Function.Name)
+ .FirstOrDefault(x => x.Equals(returnedFunctionName!.Trim(), StringComparison.Ordinal));
+ if (canonicalName is not null && !canonicalName.Equals(returnedFunctionName, StringComparison.Ordinal))
+ {
+ this.logger.LogWarning("Canonicalized tool call function name '{ReturnedFunctionName}' to '{CanonicalFunctionName}'.", returnedFunctionName, canonicalName);
+ normalizedToolCall = normalizedToolCall with
+ {
+ Function = normalizedToolCall.Function! with
{
Name = canonicalName,
},
};
- })
- .ToList();
+ }
+
+ preparedToolCalls.Add(new PreparedChatCompletionToolCall(normalizedToolCall, true));
+ }
+
+ return preparedToolCalls;
+ }
+
+ private readonly record struct PreparedChatCompletionToolCall(ChatCompletionToolCall ToolCall, bool IsValid);
private async Task ExecuteChatCompletionRequest(
ChatCompletionAPIRequest requestDto,
diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionResponseMessage.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionResponseMessage.cs
index 51fcdcce..40b20e78 100644
--- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionResponseMessage.cs
+++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionResponseMessage.cs
@@ -15,5 +15,5 @@ public sealed record ChatCompletionResponseMessage
public string? ReasoningContent { get; init; }
- public IList? ToolCalls { get; init; }
+ public IList? ToolCalls { get; init; }
}
diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCall.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCall.cs
index 4ba1ec59..36850dbf 100644
--- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCall.cs
+++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCall.cs
@@ -2,9 +2,9 @@ namespace AIStudio.Provider.OpenAI;
public sealed record ChatCompletionToolCall
{
- public string Id { get; init; } = string.Empty;
+ public string? Id { get; init; }
- public string Type { get; init; } = "function";
+ public string? Type { get; init; } = "function";
- public ChatCompletionToolFunction Function { get; init; } = new();
+ public ChatCompletionToolFunction? Function { get; init; }
}
diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolFunction.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolFunction.cs
index 248b91f2..2d24971f 100644
--- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolFunction.cs
+++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolFunction.cs
@@ -2,7 +2,7 @@ namespace AIStudio.Provider.OpenAI;
public sealed record ChatCompletionToolFunction
{
- public string Name { get; init; } = string.Empty;
+ public string? Name { get; init; }
- public string Arguments { get; init; } = string.Empty;
+ public string? Arguments { get; init; }
}
diff --git a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs
index 181f5a29..a3a4a414 100644
--- a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs
+++ b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs
@@ -366,6 +366,17 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
}
toolSources.MergeSources(response.GetSources());
+ var functionCalls = response.GetFunctionCalls();
+ if (functionCalls.Any(x => string.IsNullOrWhiteSpace(x.CallId)))
+ {
+ toolCallCount++;
+ var (invalidToolContent, invalidTrace, _, _) = toolExecutor.CreateInvalidToolCallResult(string.Empty, toolCallCount);
+ toolResultCharacterCount += invalidToolContent.Length;
+ currentAssistantContent?.ToolInvocations.Add(invalidTrace);
+ await ResetToolRuntimeStatusAsync(currentAssistantContent);
+ yield return new ContentStreamChunk(invalidToolContent, [..toolSources]);
+ yield break;
+ }
if (finalResponseRequired)
{
@@ -380,7 +391,6 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
yield break;
}
- var functionCalls = response.GetFunctionCalls();
if (functionCalls.Count == 0)
{
await ResetToolRuntimeStatusAsync(currentAssistantContent);
@@ -396,20 +406,44 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
try
{
- await ShowToolRuntimeStatusAsync(currentAssistantContent, functionCalls
- .Select(x => runnableTools.FirstOrDefault(tool => tool.Definition.Function.Name.Equals(x.Name, StringComparison.Ordinal)).Implementation?.GetDisplayName() ?? x.Name));
+ var preparedFunctionCalls = functionCalls
+ .Select(x => new PreparedResponsesFunctionCall(
+ x,
+ !string.IsNullOrWhiteSpace(x.Name) && ToolExecutor.IsValidArgumentsJson(x.Arguments)))
+ .ToList();
+ var validToolNames = preparedFunctionCalls
+ .Where(x => x.IsValid)
+ .Select(x => runnableTools.FirstOrDefault(tool => tool.Definition.Function.Name.Equals(x.FunctionCall.Name, StringComparison.Ordinal)).Implementation?.GetDisplayName() ?? x.FunctionCall.Name!)
+ .ToList();
+ if (validToolNames.Count > 0)
+ await ShowToolRuntimeStatusAsync(currentAssistantContent, validToolNames);
foreach (var outputItem in response.Output)
internalItems.Add(outputItem);
- foreach (var functionCall in functionCalls)
+ foreach (var preparedFunctionCall in preparedFunctionCalls)
{
+ var functionCall = preparedFunctionCall.FunctionCall;
+ if (!preparedFunctionCall.IsValid)
+ {
+ toolCallCount++;
+ var (invalidToolContent, invalidTrace, _, _) = toolExecutor.CreateInvalidToolCallResult(functionCall.CallId!, toolCallCount);
+ toolResultCharacterCount += invalidToolContent.Length;
+ currentAssistantContent?.ToolInvocations.Add(invalidTrace);
+ internalItems.Add(new ResponsesFunctionCallOutputItem
+ {
+ CallId = functionCall.CallId!,
+ Output = invalidToolContent,
+ });
+ continue;
+ }
+
var toolCallsUnavailableInstruction = ToolSelectionRules.GetToolCallsUnavailableInstruction(toolCallCount, toolResultCharacterCount);
if (toolCallsUnavailableInstruction is not null)
{
internalItems.Add(new ResponsesFunctionCallOutputItem
{
- CallId = functionCall.CallId,
+ CallId = functionCall.CallId!,
Output = toolCallsUnavailableInstruction,
});
continue;
@@ -417,9 +451,9 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
toolCallCount++;
var (toolContent, trace, requiredProviderConfidence, sources) = await toolExecutor.ExecuteAsync(
- functionCall.CallId,
- functionCall.Name,
- functionCall.Arguments,
+ functionCall.CallId!,
+ functionCall.Name!,
+ functionCall.Arguments!,
runnableTools,
this,
toolCallCount,
@@ -431,7 +465,7 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
currentAssistantContent?.ToolInvocations.Add(trace);
internalItems.Add(new ResponsesFunctionCallOutputItem
{
- CallId = functionCall.CallId,
+ CallId = functionCall.CallId!,
Output = toolContent,
});
}
@@ -444,6 +478,8 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
}
}
+ private readonly record struct PreparedResponsesFunctionCall(ResponsesFunctionCallItem FunctionCall, bool IsValid);
+
private static async Task ResetToolRuntimeStatusAsync(ContentText? currentAssistantContent)
{
if (currentAssistantContent is null)
diff --git a/app/MindWork AI Studio/Provider/OpenAI/ResponsesFunctionCallItem.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesFunctionCallItem.cs
index 25114a76..c220ca77 100644
--- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesFunctionCallItem.cs
+++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesFunctionCallItem.cs
@@ -5,11 +5,11 @@ namespace AIStudio.Provider.OpenAI;
///
public sealed record ResponsesFunctionCallItem
{
- public string Type { get; init; } = string.Empty;
+ public string? Type { get; init; }
- public string CallId { get; init; } = string.Empty;
+ public string? CallId { get; init; }
- public string Name { get; init; } = string.Empty;
+ public string? Name { get; init; }
- public string Arguments { get; init; } = string.Empty;
+ public string? Arguments { get; init; }
}
diff --git a/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs
index 875553da..285bca00 100644
--- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs
+++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs
@@ -24,7 +24,6 @@ public sealed record ResponsesResponse
Name = ReadString(x, "name"),
Arguments = ReadString(x, "arguments"),
})
- .Where(x => !string.IsNullOrWhiteSpace(x.CallId) && !string.IsNullOrWhiteSpace(x.Name))
.ToList();
public string GetTextOutput()
diff --git a/app/MindWork AI Studio/Tools/SourceExtensions.cs b/app/MindWork AI Studio/Tools/SourceExtensions.cs
index 7e414e39..d8c5e1af 100644
--- a/app/MindWork AI Studio/Tools/SourceExtensions.cs
+++ b/app/MindWork AI Studio/Tools/SourceExtensions.cs
@@ -154,15 +154,37 @@ public static partial class SourceExtensions
}
///
- /// Merges a list of added sources into an existing list of sources, avoiding duplicates based on URL and Title.
+ /// Merges a list of added sources into an existing list of sources, avoiding duplicates based on normalized URLs.
///
/// The existing list of sources to merge into.
/// The list of sources to add.
public static void MergeSources(this IList sources, IEnumerable addedSources)
{
+ var sourceIdentities = sources
+ .Select(source => GetSourceIdentity(source.URL))
+ .ToHashSet(StringComparer.Ordinal);
+
foreach (var addedSource in addedSources)
- if (sources.All(s => s.URL != addedSource.URL && s.Title != addedSource.Title))
+ {
+ if (sourceIdentities.Add(GetSourceIdentity(addedSource.URL)))
sources.Add((Source)addedSource);
+ }
+ }
+
+ private static string GetSourceIdentity(string url)
+ {
+ var cleanedUrl = url.Trim().Replace("\r", string.Empty).Replace("\n", string.Empty);
+ if (!Uri.TryCreate(cleanedUrl, UriKind.Absolute, out var absoluteUri))
+ return cleanedUrl;
+
+ var normalizedUri = new UriBuilder(absoluteUri)
+ {
+ Scheme = absoluteUri.Scheme.ToLowerInvariant(),
+ Host = absoluteUri.IdnHost.TrimEnd('.').ToLowerInvariant(),
+ Port = absoluteUri.IsDefaultPort ? -1 : absoluteUri.Port,
+ Fragment = string.Empty,
+ };
+ return normalizedUri.Uri.GetComponents(UriComponents.AbsoluteUri, UriFormat.UriEscaped);
}
[GeneratedRegex(@"^\[(?