diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolParameterSchemaBuilder.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolParameterSchemaBuilder.cs
index 592c8228..0ca6e5a3 100644
--- a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolParameterSchemaBuilder.cs
+++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolParameterSchemaBuilder.cs
@@ -33,6 +33,34 @@ public sealed class ToolParameterSchemaBuilder
public ToolParameterSchemaBuilder OptionalEnum(string name, string description, params string[] allowedValues) => this.Add(name, "string", description, isRequired: false, allowedValues);
+ ///
+ /// An argument the model may leave out or pass as a list of strings.
+ ///
+ ///
+ /// With allowed values, every entry of the list has to be one of them, such as the data sources
+ /// Semantic Search may be asked to search. How many entries the list holds is for the tool to
+ /// check, like everything else a model passes.
+ ///
+ public ToolParameterSchemaBuilder OptionalStringArray(string name, string description, params string[] allowedValues)
+ {
+ var items = new JsonObject
+ {
+ ["type"] = "string",
+ };
+
+ if (allowedValues is { Length: > 0 })
+ items["enum"] = new JsonArray([..allowedValues.Select(value => JsonValue.Create(value))]);
+
+ this.properties[name] = new JsonObject
+ {
+ ["type"] = "array",
+ ["description"] = description,
+ ["items"] = items,
+ };
+
+ return this;
+ }
+
///
/// Produces the finished schema.
///
diff --git a/app/Tests/Provider/ToolCalling/OpenAIStrictToolSchemaTests.cs b/app/Tests/Provider/ToolCalling/OpenAIStrictToolSchemaTests.cs
index 9bf71d9b..297f65d6 100644
--- a/app/Tests/Provider/ToolCalling/OpenAIStrictToolSchemaTests.cs
+++ b/app/Tests/Provider/ToolCalling/OpenAIStrictToolSchemaTests.cs
@@ -45,6 +45,21 @@ public sealed class OpenAIStrictToolSchemaTests
});
}
+ [Test]
+ public void AnOptionalListMayBeNullWhileItsEntriesKeepTheirChoice()
+ {
+ var dataSourceIds = Converted(ToolParameterSchemaBuilder.Create()
+ .RequiredString("query", "The search query.")
+ .OptionalStringArray("data_source_ids", "The data sources.", "first", "second"))["properties"]!["data_source_ids"]!;
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(Types(dataSourceIds), Is.EqualTo(["array", "null"]), "Leaving the list out is said by allowing null for the list itself.");
+ Assert.That(dataSourceIds["items"]!["enum"]!.AsArray().Select(value => value?.GetValue()), Is.EqualTo(["first", "second"]), "Null is a way to leave the list out, not an entry it may hold.");
+ Assert.That(dataSourceIds["enum"], Is.Null, "The list itself names no values of its own.");
+ });
+ }
+
[Test]
public void ARequiredArgumentStaysAsItIs()
{
diff --git a/app/Tests/Tools/ToolCalling/ToolParameterSchemaBuilderTests.cs b/app/Tests/Tools/ToolCalling/ToolParameterSchemaBuilderTests.cs
new file mode 100644
index 00000000..b13f96f3
--- /dev/null
+++ b/app/Tests/Tools/ToolCalling/ToolParameterSchemaBuilderTests.cs
@@ -0,0 +1,52 @@
+using System.Text.Json.Nodes;
+
+using AIStudio.Tools.ToolCallingSystem;
+
+namespace AIStudio.Tests.Tools.ToolCalling;
+
+///
+/// Checks the plain JSON Schema a tool describes its arguments with.
+///
+///
+/// This is the form Anthropic and every host without strict mode receive as written, so it has to
+/// mean exactly what the tool expects. The translation for strict mode is checked on its own, see
+/// OpenAIStrictToolSchemaTests.
+///
+[TestFixture]
+public sealed class ToolParameterSchemaBuilderTests
+{
+ [Test]
+ public void AListOfChoicesRestrictsEveryEntry()
+ {
+ var schema = Built(ToolParameterSchemaBuilder.Create().OptionalStringArray("data_source_ids", "The data sources.", "first", "second"));
+ var property = schema["properties"]!["data_source_ids"]!;
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(property["type"]!.GetValue(), Is.EqualTo("array"));
+ Assert.That(property["description"]!.GetValue(), Is.EqualTo("The data sources."));
+ Assert.That(property["items"]!["type"]!.GetValue(), Is.EqualTo("string"));
+ Assert.That(property["items"]!["enum"]!.AsArray().Select(value => value!.GetValue()), Is.EqualTo(new[] { "first", "second" }));
+ });
+ }
+
+ [Test]
+ public void AListWithoutChoicesTakesAnyString()
+ {
+ var items = Built(ToolParameterSchemaBuilder.Create().OptionalStringArray("tags", "Some tags."))["properties"]!["tags"]!["items"]!;
+
+ Assert.That(items["enum"], Is.Null, "An empty enum would allow no entry at all rather than any.");
+ }
+
+ [Test]
+ public void AnOptionalListIsNotRequired()
+ {
+ var schema = Built(ToolParameterSchemaBuilder.Create()
+ .RequiredString("query", "The search query.")
+ .OptionalStringArray("data_source_ids", "The data sources.", "first"));
+
+ Assert.That(schema["required"]!.AsArray().Select(name => name!.GetValue()), Is.EqualTo(new[] { "query" }));
+ }
+
+ private static JsonNode Built(ToolParameterSchemaBuilder builder) => JsonNode.Parse(builder.Build().GetRawText())!;
+}
\ No newline at end of file