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