mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-13 10:47:21 +00:00
Improved how the AI remembers its earlier tool calls (#1185)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
1 parent
0885862150
commit
382db4b8d0
22 files changed
+1323
-24
No files matched your search
@@ -241,6 +241,7 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
|
||||
context.ChatThread.RequireDataSecurity(outcome.RequiredDataSecurity);
|
||||
context.ChatThread.RequireOutboundDataRestriction(outcome.RequiredOutboundDataRestriction);
|
||||
context.ChatThread.RuntimeWebAddressesFromTools.UnionWith(outcome.ReturnedWebAddresses);
|
||||
context.CurrentAssistantContent?.KeepWebAddressesFromTools(outcome.ReturnedWebAddresses);
|
||||
toolSources.MergeSources(outcome.Sources);
|
||||
toolPassages.MergePassages(outcome.Passages);
|
||||
await context.AddToolInvocationAsync(outcome.Trace);
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
using System.Buffers;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Tools.Web;
|
||||
|
||||
namespace AIStudio.Tools.ToolCallingSystem;
|
||||
|
||||
/// <summary>
|
||||
/// The note telling the model which tools and sources an earlier answer of it rested on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The results of the tools do not travel into later messages, see ToolInvocationTrace.Result. Without
|
||||
/// this note a model would see only the answers it wrote, believe it never read a web page, and refuse
|
||||
/// to read it again. The note names what was fetched, never its content: the model calls the tool again
|
||||
/// when it needs the content, which costs nothing while it does not.<br/><br/>
|
||||
///
|
||||
/// The note stands in front of the answer in the request only, never in ContentText.Text, so it does not
|
||||
/// reach a copy, an export, or a text sent on. It never goes into the system prompt either: the addresses
|
||||
/// there count as given to the model, see ChatThread.IsWebAddressGivenToTheModel, and the note holds
|
||||
/// addresses the model chose itself.<br/><br/>
|
||||
///
|
||||
/// Written in English and not translated, because only the model reads it.
|
||||
/// </remarks>
|
||||
public static partial class ToolCallHistoryNote
|
||||
{
|
||||
public const string START_TAG = "<ai_studio_history_note>";
|
||||
|
||||
public const string END_TAG = "</ai_studio_history_note>";
|
||||
|
||||
/// <summary>
|
||||
/// The most tool calls one note names.
|
||||
/// </summary>
|
||||
public const int MAX_CALLS = 20;
|
||||
|
||||
/// <summary>
|
||||
/// The most sources one note names.
|
||||
/// </summary>
|
||||
public const int MAX_SOURCES = 20;
|
||||
|
||||
/// <summary>
|
||||
/// The longest web address of a source a note names, see DescribeSources.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// About what browsers and servers accept. A source with a longer address is left out rather than
|
||||
/// cut, since a cut address leads nowhere.
|
||||
/// </remarks>
|
||||
public const int MAX_SOURCE_URL_CHARACTERS = 2_000;
|
||||
|
||||
/// <summary>
|
||||
/// The most characters of one argument value a note shows.
|
||||
/// </summary>
|
||||
public const int MAX_ARGUMENT_CHARACTERS = 300;
|
||||
|
||||
/// <summary>
|
||||
/// What a note shows for an argument value the tool marked as sensitive, see
|
||||
/// IToolImplementation.SensitiveTraceArgumentNames, or which the trace did not keep.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not the mask the user sees in the trace: the model would pass it back word for word and, e.g.,
|
||||
/// search for five asterisks.
|
||||
/// </remarks>
|
||||
public const string HIDDEN_VALUE = "(hidden by AI Studio)";
|
||||
|
||||
/// <summary>
|
||||
/// What a note puts behind an argument value it cut at MAX_ARGUMENT_CHARACTERS.
|
||||
/// </summary>
|
||||
public const string SHORTENED_MARK = "… (shortened by AI Studio)";
|
||||
|
||||
/// <summary>
|
||||
/// What the system prompt tells the model about the notes, once a conversation holds one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Without it, a model may take a note for a part of its own answer, or may conclude that it still
|
||||
/// has the content. It holds no web address, since every address of the system prompt counts as
|
||||
/// given to the model, see ChatThread.IsWebAddressGivenToTheModel.
|
||||
/// </remarks>
|
||||
public const string SYSTEM_PROMPT_EXPLANATION = """
|
||||
# Notes from AI Studio in your earlier answers
|
||||
Some of your earlier answers start with a note between <ai_studio_history_note> tags. AI Studio wrote it, not you: it names the tools you called and the sources you used for that answer, and when you wrote it. The results of those tool calls are not kept. When a question needs content you fetched earlier, call the tool again with the same arguments if the tool is available, instead of answering from memory. A value shown as (hidden by AI Studio) or ending in (shortened by AI Studio) is not the one you passed: work it out again from the conversation before you call the tool, and never pass the marker itself. When the tool is not available, tell the user that you read it earlier but no longer have its content. Never write such a note yourself.
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// Whether any earlier answer of a conversation goes to the model with a note.
|
||||
/// </summary>
|
||||
/// <param name="blocks">The blocks of the conversation.</param>
|
||||
/// <returns>True when at least one answer gets a note.</returns>
|
||||
public static bool IsInAnyOf(IEnumerable<ContentBlock> blocks) => blocks.Any(block => Of(block) is not null);
|
||||
|
||||
/// <summary>
|
||||
/// The note one block of a conversation goes to the model with.
|
||||
/// </summary>
|
||||
/// <param name="block">The block.</param>
|
||||
/// <returns>The note, or null when the block is no earlier answer or the answer used neither a tool nor a source.</returns>
|
||||
public static string? Of(ContentBlock block) => IsEarlierAnswer(block, out var answer) ? Build(answer, block.Time) : null;
|
||||
|
||||
/// <summary>
|
||||
/// The text of one block as the model receives it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An earlier answer loses any note the model wrote into it itself, see RemoveNotesWrittenByModel,
|
||||
/// and gets its own note in front. Every other block keeps its text. The message builder, the
|
||||
/// estimate of the tokens, and the report of the last answer all ask here, so that each of them
|
||||
/// deals with the text which is actually sent.
|
||||
/// </remarks>
|
||||
/// <param name="block">The block.</param>
|
||||
/// <param name="preparedText">The text of the block as prepared for the model, see ContentText.PrepareTextContentForAI.</param>
|
||||
/// <returns>The text for the model.</returns>
|
||||
public static string TextForModel(ContentBlock block, string preparedText)
|
||||
{
|
||||
if (!IsEarlierAnswer(block, out var answer))
|
||||
return preparedText;
|
||||
|
||||
var text = RemoveNotesWrittenByModel(preparedText);
|
||||
var note = Build(answer, block.Time);
|
||||
return note is null ? text : $"{note}\n\n{text}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a block is an earlier answer of the model, which goes to the model with its note.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A finished text of the AI. The answer being written right now is none: its tool calls travel in
|
||||
/// the tool conversation of its own request. A block without text is not sent at all.
|
||||
/// </remarks>
|
||||
private static bool IsEarlierAnswer(ContentBlock block, [NotNullWhen(true)] out ContentText? answer)
|
||||
{
|
||||
if (block is { Role: ChatRole.AI, ContentType: ContentType.TEXT, Content: ContentText { IsStreaming: false } text } && !string.IsNullOrWhiteSpace(text.Text))
|
||||
{
|
||||
answer = text;
|
||||
return true;
|
||||
}
|
||||
|
||||
answer = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static readonly JsonWriterOptions ARGUMENT_WRITER_OPTIONS = new()
|
||||
{
|
||||
// Only the model reads the note, never a browser, so umlauts and the like may stay as they are:
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds the note for one answer of the model.
|
||||
/// </summary>
|
||||
/// <param name="answer">The answer.</param>
|
||||
/// <param name="time">When the answer was written.</param>
|
||||
/// <returns>The note, or null when the answer used neither a tool nor a source.</returns>
|
||||
public static string? Build(ContentText answer, DateTimeOffset time)
|
||||
{
|
||||
var calls = DescribeCalls(answer.ToolInvocations, out var argumentRequestKeys, out var furtherCalls);
|
||||
var sources = DescribeSources(answer.Sources, argumentRequestKeys, out var furtherSources);
|
||||
if (calls.Count is 0 && sources.Count is 0)
|
||||
return null;
|
||||
|
||||
var note = new StringBuilder();
|
||||
note.AppendLineLF(START_TAG);
|
||||
note.AppendLineLF(string.Create(CultureInfo.InvariantCulture, $"Note from AI Studio, not part of your answer. For the answer below, written {time.UtcDateTime:yyyy-MM-dd HH:mm} UTC, you used:"));
|
||||
|
||||
if (calls.Count > 0)
|
||||
{
|
||||
note.AppendLineLF("Tool calls (their results are not kept; call the tool again if you need the content):");
|
||||
foreach (var call in calls)
|
||||
note.AppendLineLF($"- {call}");
|
||||
|
||||
if (furtherCalls > 0)
|
||||
note.AppendLineLF(string.Create(CultureInfo.InvariantCulture, $"- … ({furtherCalls} more)"));
|
||||
}
|
||||
|
||||
if (sources.Count > 0)
|
||||
{
|
||||
note.AppendLineLF("Sources:");
|
||||
foreach (var source in sources)
|
||||
note.AppendLineLF($"- {source}");
|
||||
|
||||
if (furtherSources > 0)
|
||||
note.AppendLineLF(string.Create(CultureInfo.InvariantCulture, $"- … ({furtherSources} more)"));
|
||||
}
|
||||
|
||||
note.Append(END_TAG);
|
||||
return note.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes every note a model wrote into its answer itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A model which sees the note in front of its earlier answers may start writing one of its own.
|
||||
/// Sent back, such a note would claim tool calls which never happened.
|
||||
/// </remarks>
|
||||
/// <param name="text">The text of an answer.</param>
|
||||
/// <returns>The text without notes.</returns>
|
||||
public static string RemoveNotesWrittenByModel(string text) => text.Contains(START_TAG, StringComparison.OrdinalIgnoreCase)
|
||||
? NotePattern().Replace(text, string.Empty)
|
||||
: text;
|
||||
|
||||
[GeneratedRegex(@"<ai_studio_history_note>.*?(</ai_studio_history_note>|\z)", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
|
||||
private static partial Regex NotePattern();
|
||||
|
||||
/// <summary>
|
||||
/// One line per tool call: the calls which ran with their arguments, the others with their outcome only.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A call which failed or was blocked is named, so the model does not repeat it blindly, but without
|
||||
/// its arguments and its message: the message may quote what went wrong, content included.
|
||||
/// </remarks>
|
||||
/// <param name="traces">The calls of the answer.</param>
|
||||
/// <param name="argumentRequestKeys">The request keys of the web addresses in the arguments of the calls named with them.</param>
|
||||
/// <param name="furtherCalls">How many calls the cap left out.</param>
|
||||
/// <returns>The lines.</returns>
|
||||
private static List<string> DescribeCalls(IEnumerable<ToolInvocationTrace> traces, out HashSet<string> argumentRequestKeys, out int furtherCalls)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
argumentRequestKeys = new(StringComparer.Ordinal);
|
||||
furtherCalls = 0;
|
||||
|
||||
foreach (var trace in traces.OrderBy(trace => trace.Order))
|
||||
{
|
||||
// An invalid call has no tool to name:
|
||||
if (string.IsNullOrWhiteSpace(trace.ToolId))
|
||||
continue;
|
||||
|
||||
var line = trace.Status switch
|
||||
{
|
||||
ToolInvocationTraceStatus.SUCCESS when trace.WasExecuted => $"{trace.ToolId} {WriteArguments(trace)}",
|
||||
ToolInvocationTraceStatus.BLOCKED => $"{trace.ToolId} -> blocked",
|
||||
ToolInvocationTraceStatus.ERROR => $"{trace.ToolId} -> failed",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (line is null || !seen.Add(line))
|
||||
continue;
|
||||
|
||||
if (lines.Count >= MAX_CALLS)
|
||||
{
|
||||
furtherCalls++;
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.Add(line);
|
||||
if (trace.Status is not ToolInvocationTraceStatus.SUCCESS)
|
||||
continue;
|
||||
|
||||
foreach (var address in trace.FindKeptTexts().SelectMany(WebAddresses.Find))
|
||||
{
|
||||
if (WebAddresses.TryCreateRequestKey(address, out var requestKey))
|
||||
argumentRequestKeys.Add(requestKey);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The arguments of a call as one JSON object, so the model can pass them again as they are.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Read from ToolInvocationTrace.ArgumentsJson, which keeps every value with its JSON type, rather
|
||||
/// than from the texts the user reads, which do not. An argument the trace names without keeping
|
||||
/// its value was sensitive and stays hidden; so do all values of a trace from before ArgumentsJson
|
||||
/// existed. A long text is cut, also inside an array or an object, so that every value keeps its
|
||||
/// type. Both are marked, see HIDDEN_VALUE and SHORTENED_MARK, so the model does not pass them back
|
||||
/// as they stand.
|
||||
/// </remarks>
|
||||
private static string WriteArguments(ToolInvocationTrace trace)
|
||||
{
|
||||
using var keptArguments = trace.ReadKeptArguments();
|
||||
var keptValues = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
|
||||
if (keptArguments is not null)
|
||||
{
|
||||
foreach (var property in keptArguments.RootElement.EnumerateObject())
|
||||
keptValues.TryAdd(property.Name, property.Value);
|
||||
}
|
||||
|
||||
var names = new SortedSet<string>(trace.Arguments.Keys, StringComparer.Ordinal);
|
||||
names.UnionWith(keptValues.Keys);
|
||||
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(buffer, ARGUMENT_WRITER_OPTIONS))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
foreach (var name in names)
|
||||
{
|
||||
writer.WritePropertyName(name);
|
||||
if (keptValues.TryGetValue(name, out var value))
|
||||
WriteValue(writer, value);
|
||||
else
|
||||
writer.WriteStringValue(HIDDEN_VALUE);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(buffer.WrittenSpan);
|
||||
}
|
||||
|
||||
private static void WriteValue(Utf8JsonWriter writer, JsonElement value)
|
||||
{
|
||||
switch (value.ValueKind)
|
||||
{
|
||||
case JsonValueKind.String:
|
||||
var text = value.GetString() ?? string.Empty;
|
||||
writer.WriteStringValue(text.Length > MAX_ARGUMENT_CHARACTERS ? $"{text[..MAX_ARGUMENT_CHARACTERS]}{SHORTENED_MARK}" : text);
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in value.EnumerateArray())
|
||||
WriteValue(writer, item);
|
||||
|
||||
writer.WriteEndArray();
|
||||
break;
|
||||
|
||||
case JsonValueKind.Object:
|
||||
writer.WriteStartObject();
|
||||
foreach (var property in value.EnumerateObject())
|
||||
{
|
||||
writer.WritePropertyName(property.Name);
|
||||
WriteValue(writer, property.Value);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
|
||||
default:
|
||||
value.WriteTo(writer);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One line per source: where it came from and its web address.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Without the title: a title is text of a foreign page, which reached the model through the
|
||||
/// prompt injection filter once, and would reach it unfiltered with every later message.<br/><br/>
|
||||
///
|
||||
/// Only sources with a web address, since the note is there for the model to call a tool again,
|
||||
/// and no tool takes the other ones. A data source names a local file as a file address, with a
|
||||
/// path of the user, and builds an address of its own from the title and the path of a passage,
|
||||
/// both unfiltered; a mail goes by its mailbox and its ID. A web address goes only up to
|
||||
/// MAX_SOURCE_URL_CHARACTERS and without any white space, so that no address can fill the note
|
||||
/// or write lines of its own into it.<br/><br/>
|
||||
///
|
||||
/// A source whose address the model passed to a tool already stands with that call. Both are
|
||||
/// compared by what they ask the server for, see WebAddresses.CreateRequestKey, since a source
|
||||
/// holds the address in the form the page was read under, e.g., with a slash added.
|
||||
/// </remarks>
|
||||
/// <param name="sources">The sources of the answer.</param>
|
||||
/// <param name="argumentRequestKeys">The request keys of the web addresses the calls named in the note hold.</param>
|
||||
/// <param name="furtherSources">How many sources the cap left out.</param>
|
||||
/// <returns>The lines.</returns>
|
||||
private static List<string> DescribeSources(IEnumerable<Source> sources, HashSet<string> argumentRequestKeys, out int furtherSources)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
furtherSources = 0;
|
||||
|
||||
foreach (var source in sources)
|
||||
{
|
||||
if (source.URL.Length > MAX_SOURCE_URL_CHARACTERS || source.URL.Any(char.IsWhiteSpace))
|
||||
continue;
|
||||
|
||||
if (!WebAddresses.TryCreateRequestKey(source.URL, out var requestKey) || !seen.Add(requestKey))
|
||||
continue;
|
||||
|
||||
if (argumentRequestKeys.Contains(requestKey))
|
||||
continue;
|
||||
|
||||
if (lines.Count >= MAX_SOURCES)
|
||||
{
|
||||
furtherSources++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var origin = source.Origin switch
|
||||
{
|
||||
SourceOrigin.TOOL => "tool",
|
||||
SourceOrigin.RAG => "data source",
|
||||
SourceOrigin.LLM => "search of the provider",
|
||||
_ => "unknown",
|
||||
};
|
||||
|
||||
lines.Add($"[{origin}] {source.URL}");
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Buffers;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
@@ -14,6 +16,11 @@ public sealed class ToolExecutor(ToolSettingsService toolSettingsService, ILogge
|
||||
{
|
||||
private const string INVALID_TOOL_CALL_ERROR = "The tool call was invalid.";
|
||||
|
||||
/// <summary>
|
||||
/// What the trace keeps of an argument the tool marked as sensitive, see IToolImplementation.SensitiveTraceArgumentNames.
|
||||
/// </summary>
|
||||
private const string MASKED_ARGUMENT = "*****";
|
||||
|
||||
/// <summary>
|
||||
/// How the arguments are read for running a tool: every name only once per object.
|
||||
/// </summary>
|
||||
@@ -90,10 +97,13 @@ public sealed class ToolExecutor(ToolSettingsService toolSettingsService, ILogge
|
||||
{
|
||||
var runnableTool = runnableTools.FirstOrDefault(x => x.Definition.Function.Name.Equals(toolName, StringComparison.Ordinal));
|
||||
Dictionary<string, string> formattedArguments = [];
|
||||
var traceArgumentsJson = string.Empty;
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
|
||||
formattedArguments = FormatArguments(document.RootElement, runnableTool.Implementation?.SensitiveTraceArgumentNames ?? EmptySensitiveTraceArgumentNames.INSTANCE);
|
||||
var sensitiveNames = runnableTool.Implementation?.SensitiveTraceArgumentNames ?? EmptySensitiveTraceArgumentNames.INSTANCE;
|
||||
formattedArguments = FormatArguments(document.RootElement, sensitiveNames);
|
||||
traceArgumentsJson = WriteTraceArgumentsJson(document.RootElement, sensitiveNames);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
@@ -124,6 +134,7 @@ public sealed class ToolExecutor(ToolSettingsService toolSettingsService, ILogge
|
||||
Status = ToolInvocationTraceStatus.BLOCKED,
|
||||
StatusMessage = "Tool is not available in the current context.",
|
||||
Arguments = formattedArguments,
|
||||
ArgumentsJson = traceArgumentsJson,
|
||||
Result = error,
|
||||
});
|
||||
}
|
||||
@@ -170,6 +181,7 @@ public sealed class ToolExecutor(ToolSettingsService toolSettingsService, ILogge
|
||||
WasExecuted = true,
|
||||
Arguments = FormatArguments(document.RootElement,
|
||||
implementation.SensitiveTraceArgumentNames),
|
||||
ArgumentsJson = WriteTraceArgumentsJson(document.RootElement, implementation.SensitiveTraceArgumentNames),
|
||||
Result = result.TextContent ?? string.Empty,
|
||||
JsonResult = result.JsonContent,
|
||||
};
|
||||
@@ -203,6 +215,7 @@ public sealed class ToolExecutor(ToolSettingsService toolSettingsService, ILogge
|
||||
Status = ToolInvocationTraceStatus.BLOCKED,
|
||||
StatusMessage = exception.Message,
|
||||
Arguments = formattedArguments,
|
||||
ArgumentsJson = traceArgumentsJson,
|
||||
Result = exception.Message,
|
||||
};
|
||||
|
||||
@@ -223,6 +236,7 @@ public sealed class ToolExecutor(ToolSettingsService toolSettingsService, ILogge
|
||||
Status = ToolInvocationTraceStatus.ERROR,
|
||||
StatusMessage = error,
|
||||
Arguments = formattedArguments,
|
||||
ArgumentsJson = traceArgumentsJson,
|
||||
Result = error,
|
||||
};
|
||||
|
||||
@@ -277,7 +291,12 @@ public sealed class ToolExecutor(ToolSettingsService toolSettingsService, ILogge
|
||||
return requestKeys;
|
||||
}
|
||||
|
||||
private static void CollectStrings(JsonElement element, List<string> texts)
|
||||
/// <summary>
|
||||
/// Collects every text value of a JSON value, also those inside arrays and objects, but no property names.
|
||||
/// </summary>
|
||||
/// <param name="element">The JSON value.</param>
|
||||
/// <param name="texts">Where the texts go.</param>
|
||||
internal static void CollectStrings(JsonElement element, List<string> texts)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
@@ -330,7 +349,7 @@ public sealed class ToolExecutor(ToolSettingsService toolSettingsService, ILogge
|
||||
foreach (var property in rootElement.EnumerateObject())
|
||||
{
|
||||
arguments[property.Name] = sensitiveNames.Contains(property.Name)
|
||||
? "*****"
|
||||
? MASKED_ARGUMENT
|
||||
: property.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => property.Value.GetString() ?? string.Empty,
|
||||
@@ -340,4 +359,35 @@ public sealed class ToolExecutor(ToolSettingsService toolSettingsService, ILogge
|
||||
|
||||
return arguments;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The arguments of a call as one JSON object for its trace, without the values of the sensitive ones.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each value is written as the model passed it, so that its JSON type survives, see
|
||||
/// ToolInvocationTrace.ArgumentsJson. A name which appears twice is written once: such a call
|
||||
/// never runs, see EXECUTION_ARGUMENT_OPTIONS, and only the calls which ran show their arguments
|
||||
/// to the model later on.
|
||||
/// </remarks>
|
||||
private static string WriteTraceArgumentsJson(JsonElement rootElement, IReadOnlySet<string> sensitiveNames)
|
||||
{
|
||||
if (rootElement.ValueKind is not JsonValueKind.Object)
|
||||
return string.Empty;
|
||||
|
||||
var writtenNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(buffer))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
foreach (var property in rootElement.EnumerateObject())
|
||||
{
|
||||
if (!sensitiveNames.Contains(property.Name) && writtenNames.Add(property.Name))
|
||||
property.WriteTo(writer);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(buffer.WrittenSpan);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
@@ -23,9 +24,65 @@ public sealed class ToolInvocationTrace
|
||||
|
||||
public Dictionary<string, string> Arguments { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The arguments as the model passed them, as one JSON object, but without the values of the
|
||||
/// arguments the tool marked as sensitive.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Arguments holds the values as text for the user to read, which loses their JSON type: a
|
||||
/// boolean reads "True", and a query "2026" reads like a number. The note which tells the model
|
||||
/// in a later message how it called the tool needs the values as the model passed them, see
|
||||
/// ToolCallHistoryNote. A name which Arguments holds but this object does not was sensitive. A
|
||||
/// trace from before this property existed holds an empty text, so all of its arguments count
|
||||
/// as such: the note then hides their values instead of guessing their types.
|
||||
/// </remarks>
|
||||
public string ArgumentsJson { get; set; } = string.Empty;
|
||||
|
||||
[JsonIgnore]
|
||||
public string Result { get; set; } = string.Empty;
|
||||
|
||||
[JsonIgnore]
|
||||
public JsonNode? JsonResult { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The arguments this trace kept with their values, see ArgumentsJson.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// AI Studio writes them itself, see ToolExecutor. Text which is no JSON object, as in a chat file
|
||||
/// changed by hand, counts as nothing kept, so the values of all arguments stay unknown.
|
||||
/// </remarks>
|
||||
/// <returns>The arguments as one JSON object, which the caller disposes, or null when none were kept.</returns>
|
||||
public JsonDocument? ReadKeptArguments()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(this.ArgumentsJson))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var document = JsonDocument.Parse(this.ArgumentsJson);
|
||||
if (document.RootElement.ValueKind is JsonValueKind.Object)
|
||||
return document;
|
||||
|
||||
document.Dispose();
|
||||
return null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The texts among the kept arguments, also those inside an array or an object.
|
||||
/// </summary>
|
||||
/// <returns>The texts, which leave out the values of sensitive arguments.</returns>
|
||||
public List<string> FindKeptTexts()
|
||||
{
|
||||
var texts = new List<string>();
|
||||
using var keptArguments = this.ReadKeptArguments();
|
||||
if (keptArguments is not null)
|
||||
ToolExecutor.CollectStrings(keptArguments.RootElement, texts);
|
||||
|
||||
return texts;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user