Added a way to read what the AI received from your sources (#1100)

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
Paul KoudelkaandThorsten Sommer authored and GitHub committed 2026-10-26 10:00:01 +01:00
1 parent e0d8e18f34
commit cbc4ce5413
229 files changed
+12739 -1269

No files matched your search

@@ -1,6 +1,7 @@
using System.Runtime.CompilerServices;
using AIStudio.Provider;
using AIStudio.Tools.RAG;
namespace AIStudio.Tools.ToolCallingSystem.Harness;
@@ -38,6 +39,7 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
var toolCallCount = 0;
var toolResultCharacterCount = 0L;
var toolSources = new List<Source>();
var toolPassages = new List<SourcePassage>();
var hasStreamedTextBefore = false;
var isFirstRound = true;
@@ -111,11 +113,15 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
//
// No outcome means the round failed: the request errored out, or the stream ended
// mid-sentence. Either way the adapter has already reported it.
// mid-sentence. Either way the adapter has already reported it. The answer keeps what
// was written so far, and with it what the tools of the earlier rounds contributed:
//
if (round is null)
{
await context.ResetToolRuntimeStatusAsync();
if (toolSources.Count > 0 || toolPassages.Count > 0)
yield return WithWhatTheToolsContributed(string.Empty);
yield break;
}
@@ -133,7 +139,7 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
var unanswerable = context.ToolExecutor.CreateInvalidToolCallResult(string.Empty, toolCallCount);
await context.AddToolInvocationAsync(unanswerable.Trace);
await context.ResetToolRuntimeStatusAsync();
yield return new ContentStreamChunk(unanswerable.Content, [..toolSources]);
yield return WithWhatTheToolsContributed(unanswerable.Content);
yield break;
}
@@ -146,10 +152,7 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
// the tools contributed. An empty chunk is how sources travel on their own; the
// streaming paths of the providers attach their annotations the same way.
//
yield return new ContentStreamChunk(
roundAnswered ? string.Empty : NO_ANSWER_AFTER_LIMIT,
[..toolSources]);
yield return WithWhatTheToolsContributed(roundAnswered ? string.Empty : NO_ANSWER_AFTER_LIMIT);
yield break;
}
@@ -158,13 +161,13 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
await context.ResetToolRuntimeStatusAsync();
if (roundAnswered)
{
yield return new ContentStreamChunk(string.Empty, [..toolSources]);
yield return WithWhatTheToolsContributed(string.Empty);
yield break;
}
if (toolCallCount > 0)
{
yield return new ContentStreamChunk(NO_ANSWER_AFTER_TOOL_CALL, [..toolSources]);
yield return WithWhatTheToolsContributed(NO_ANSWER_AFTER_TOOL_CALL);
yield break;
}
@@ -239,6 +242,7 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
context.ChatThread.RequireOutboundDataRestriction(outcome.RequiredOutboundDataRestriction);
context.ChatThread.RuntimeWebAddressesFromTools.UnionWith(outcome.ReturnedWebAddresses);
toolSources.MergeSources(outcome.Sources);
toolPassages.MergePassages(outcome.Passages);
await context.AddToolInvocationAsync(outcome.Trace);
// A blocked call counts as a failure towards the model as much as an errored
@@ -252,6 +256,13 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
await context.ResetToolRuntimeStatusAsync();
}
}
//
// What the tools contributed goes out with the last chunk of the conversation: the sources,
// and the passages the model received from them. One way out for every way the conversation
// ends, so none of them hands over the sources and forgets the passages.
//
ContentStreamChunk WithWhatTheToolsContributed(string content) => new(content, [..toolSources]) { Passages = [..toolPassages] };
}
private static string GetDisplayName(ToolCallingLoopContext context, string toolName) => context.RunnableTools