From e9460a9cdefb8c97561a725ca9c204732dfd3c6c Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Thu, 24 Sep 2026 14:57:55 +0200 Subject: [PATCH] Let tools take a list of strings as an argument --- .../ToolParameterSchemaBuilder.cs | 28 ++++++++++ .../OpenAIStrictToolSchemaTests.cs | 15 ++++++ .../ToolParameterSchemaBuilderTests.cs | 52 +++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 app/Tests/Tools/ToolCalling/ToolParameterSchemaBuilderTests.cs 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