From 38cd0c9f36cca7cca27f3a585b73b071f9db24ba Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Thu, 24 Sep 2026 17:20:58 +0200 Subject: [PATCH] Keep tool results readable for the model --- .../ToolCallingSystem/ToolExecutionResult.cs | 16 +++++++- .../ToolCalling/ToolExecutionResultTests.cs | 39 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 app/Tests/Tools/ToolCalling/ToolExecutionResultTests.cs diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolExecutionResult.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolExecutionResult.cs index d3ec45ee..66bddd84 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolExecutionResult.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolExecutionResult.cs @@ -1,3 +1,5 @@ +using System.Text.Encodings.Web; +using System.Text.Json; using System.Text.Json.Nodes; using AIStudio.Provider; @@ -7,6 +9,18 @@ namespace AIStudio.Tools.ToolCallingSystem; public sealed class ToolExecutionResult { + /// + /// How a JSON result is written for the model. + /// + /// + /// The result goes into the request as a string, and the request is serialized once more on its + /// way to the provider, so the model reads whatever this escapes as the escape itself. The + /// default encoder escapes every character outside ASCII and those HTML treats specially, for + /// JSON embedded in a web page, which this never is: a German document would reach the model + /// with every umlaut as six characters. The relaxed encoder escapes only what JSON requires. + /// + private static readonly JsonSerializerOptions MODEL_CONTENT_OPTIONS = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + public string? TextContent { get; init; } public JsonNode? JsonContent { get; init; } @@ -29,7 +43,7 @@ public sealed class ToolExecutionResult public string ToModelContent() { if (this.JsonContent is not null) - return this.JsonContent.ToJsonString(); + return this.JsonContent.ToJsonString(MODEL_CONTENT_OPTIONS); return this.TextContent ?? string.Empty; } diff --git a/app/Tests/Tools/ToolCalling/ToolExecutionResultTests.cs b/app/Tests/Tools/ToolCalling/ToolExecutionResultTests.cs new file mode 100644 index 00000000..24c12390 --- /dev/null +++ b/app/Tests/Tools/ToolCalling/ToolExecutionResultTests.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +using AIStudio.Tools.ToolCallingSystem; + +namespace AIStudio.Tests.Tools.ToolCalling; + +/// +/// Checks what a model reads of a tool result. +/// +/// +/// The result goes into the request as a string, and the request is serialized once more on its way +/// to the provider. Whatever the first serialization escapes therefore reaches the model as the +/// escape itself: a German document would arrive with every umlaut spelled out as six characters, +/// and a piece of code with every angle bracket. That costs tokens, the budget of all tool results +/// counts it, and a model quoting a name from it may quote the escape. +/// +[TestFixture] +public sealed class ToolExecutionResultTests +{ + [TestCase("Größe der Übersicht")] + [TestCase("if (a < b && c > d) return 'x';")] + [TestCase("日本語のテキスト")] + public void TextReachesTheModelAsWritten(string text) + { + var result = new ToolExecutionResult { JsonContent = new JsonObject { ["text_content"] = text } }; + Assert.That(result.ToModelContent(), Does.Contain(text)); + } + + [Test] + public void TheResultStaysValidJson() + { + const string TEXT = "A \"quoted\" line\nand a backslash \\ at its end."; + var result = new ToolExecutionResult { JsonContent = new JsonObject { ["text_content"] = TEXT } }; + + var readBack = JsonSerializer.Deserialize(result.ToModelContent()); + Assert.That(readBack?["text_content"]?.GetValue(), Is.EqualTo(TEXT), "What JSON itself has to escape, it still escapes."); + } +} \ No newline at end of file