From 81d7f41538c0cd381472ddb851bd79295618d984 Mon Sep 17 00:00:00 2001 From: "nick.yi" Date: Mon, 21 Sep 2026 14:57:22 +0800 Subject: [PATCH] 1.fix(routing): drop past tool results from the router conversation block 2.feat(conversation): trim tool results that older turns replay --- .../Settings/ConversationSetting.cs | 16 ++ .../Conversations/ConversationPlugin.cs | 3 + .../Conversations/Hooks/ToolResultTrimHook.cs | 115 +++++++++ .../RoutingService.GetConversationContent.cs | 29 ++- .../Routing/RoutingService.InvokeAgent.cs | 9 +- .../Conversations/ToolResultTrimHookTests.cs | 233 ++++++++++++++++++ 6 files changed, 389 insertions(+), 16 deletions(-) create mode 100644 src/Infrastructure/BotSharp.Core/Conversations/Hooks/ToolResultTrimHook.cs create mode 100644 tests/BotSharp.Core.UnitTests/Conversations/ToolResultTrimHookTests.cs diff --git a/src/Infrastructure/BotSharp.Abstraction/Conversations/Settings/ConversationSetting.cs b/src/Infrastructure/BotSharp.Abstraction/Conversations/Settings/ConversationSetting.cs index 018f6086c..b656af2b2 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Conversations/Settings/ConversationSetting.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/Settings/ConversationSetting.cs @@ -13,10 +13,26 @@ public class ConversationSetting public bool EnableStateLog { get; set; } public bool EnableTranslationMemory { get; set; } public CleanConversationSetting CleanSetting { get; set; } = new(); + public ToolResultTrimSetting ToolResultTrim { get; set; } = new(); public RateLimitSetting RateLimit { get; set; } = new(); public FileSelectSetting? FileSelect { get; set; } } +/// +/// Caps what an older turn's tool result costs in the prompt. The turn that ran the tool always +/// sees it whole; only what history replays is shortened. +/// +public class ToolResultTrimSetting +{ + public bool Enable { get; set; } = true; + + /// How many of the most recent turns keep their tool results verbatim. + public int KeepTurns { get; set; } = 2; + + /// A result longer than this, and older than , is shortened. + public int MaxLength { get; set; } = 500; +} + public class CleanConversationSetting { public bool Enable { get; set; } diff --git a/src/Infrastructure/BotSharp.Core/Conversations/ConversationPlugin.cs b/src/Infrastructure/BotSharp.Core/Conversations/ConversationPlugin.cs index c8f3ef1c2..93c2bed55 100644 --- a/src/Infrastructure/BotSharp.Core/Conversations/ConversationPlugin.cs +++ b/src/Infrastructure/BotSharp.Core/Conversations/ConversationPlugin.cs @@ -8,6 +8,7 @@ using BotSharp.Abstraction.Settings; using BotSharp.Abstraction.Templating; using BotSharp.Core.Coding; +using BotSharp.Core.Conversations.Hooks; using BotSharp.Core.Instructs; using BotSharp.Core.MessageHub; using BotSharp.Core.MessageHub.Observers; @@ -72,6 +73,8 @@ public void RegisterDI(IServiceCollection services, IConfiguration config) services.AddScoped(); services.AddScoped(); + + services.AddScoped(); } public bool AttachMenu(List menu) diff --git a/src/Infrastructure/BotSharp.Core/Conversations/Hooks/ToolResultTrimHook.cs b/src/Infrastructure/BotSharp.Core/Conversations/Hooks/ToolResultTrimHook.cs new file mode 100644 index 000000000..3cfc92d9b --- /dev/null +++ b/src/Infrastructure/BotSharp.Core/Conversations/Hooks/ToolResultTrimHook.cs @@ -0,0 +1,115 @@ +using System.Text.RegularExpressions; +using BotSharp.Abstraction.Conversations.Settings; + +namespace BotSharp.Core.Conversations.Hooks; + +/// +/// Shortens the tool results an older turn replays into the prompt. +/// +/// +/// It runs where history is loaded, which is exactly the turn boundary: the messages a turn +/// produces are appended to the list in memory and never pass through here, so the turn that ran +/// a tool always reads its result whole. Only Content is touched -- the call itself stays +/// intact, so a later turn can still tell that the function already ran, and storage keeps the +/// full text either way. +/// +public class ToolResultTrimHook : ConversationHookBase +{ + /// The opening of a structured document: a brace or bracket that starts one, not a word in prose. + private static readonly Regex StructuredStart = new(@"[{\[]\s*[""{\[\d-]", RegexOptions.Compiled); + + private readonly IServiceProvider _services; + private readonly ConversationSetting _settings; + private readonly ILogger _logger; + + public ToolResultTrimHook( + IServiceProvider services, + ConversationSetting settings, + ILogger logger) + { + _services = services; + _settings = settings; + _logger = logger; + } + + public override string SelfId => string.Empty; + + public override Task OnDialogsLoaded(List dialogs) + { + var setting = _settings.ToolResultTrim; + if (!setting.Enable || dialogs.IsNullOrEmpty()) + { + return base.OnDialogsLoaded(dialogs); + } + + // A turn is a message id: everything a turn derives carries the id of the message that + // started it. The current one is skipped as well, because a function that reloads the + // history mid-turn would otherwise be handed a shortened copy of what it just produced. + var currentMessageId = _services.GetRequiredService().MessageId; + var recentTurns = dialogs + .Select(x => x.MessageId) + .Where(x => !string.IsNullOrEmpty(x)) + .Distinct() + .TakeLast(Math.Max(setting.KeepTurns, 0)) + .ToHashSet(); + + var trimmed = 0; + var saved = 0; + + foreach (var dialog in dialogs) + { + if (dialog.Role != AgentRole.Function + || dialog.MessageId == currentMessageId + || recentTurns.Contains(dialog.MessageId) + || (dialog.Content?.Length ?? 0) <= setting.MaxLength) + { + continue; + } + + var before = dialog.Content.Length; + dialog.Content = Shorten(dialog, setting.MaxLength); + + trimmed++; + saved += before - dialog.Content.Length; + } + + if (trimmed > 0) + { + _logger.LogInformation( + "[ToolResultTrim] {Count} tool result(s) older than {KeepTurns} turn(s) shortened, {Saved} characters kept out of the prompt.", + trimmed, setting.KeepTurns, saved); + } + + return base.OnDialogsLoaded(dialogs); + } + + /// + /// Keeps the head of a rendered result, whose useful part comes first, and replaces a + /// structured one outright: half a JSON document is not something a model can read, and it + /// cannot tell that the half it got is not the whole. + /// + /// + /// The cut goes wherever comes first: where a structured value begins, or the length cap. A + /// tool is free to answer with a sentence and then a document -- an MCP server returning + /// several text blocks does exactly that -- and cutting inside the document would leave a + /// model something it cannot read and cannot tell is incomplete, while cutting in front of it + /// keeps the sentence that introduced it. A lone brace in prose is not a document: what is + /// looked for is a brace or bracket that opens a value. + /// + private static string Shorten(RoleDialogModel dialog, int maxLength) + { + var content = dialog.Content; + var structure = StructuredStart.Match(content); + var cut = structure.Success ? Math.Min(structure.Index, maxLength) : maxLength; + + // The call itself sits in the message right before this one, so the note does not name it + // again -- what a later turn cannot work out on its own is that something was dropped. + if (cut == 0) + { + return $"[{content.Length} chars omitted; call again for detail]"; + } + + return content[..cut].TrimEnd() + + $"\r\n... [{content.Length - cut} chars omitted; call again for detail]"; + } +} diff --git a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.GetConversationContent.cs b/src/Infrastructure/BotSharp.Core/Routing/RoutingService.GetConversationContent.cs index 256f5d065..b1d40f23d 100644 --- a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.GetConversationContent.cs +++ b/src/Infrastructure/BotSharp.Core/Routing/RoutingService.GetConversationContent.cs @@ -5,28 +5,35 @@ public partial class RoutingService public async Task GetConversationContent(List dialogs, int maxDialogCount = 100) { var agentService = _services.GetRequiredService(); - var conversation = ""; - var conversationDialogs = dialogs.Where(x => !x.ExcludeFromContext).TakeLast(maxDialogCount).ToList(); + var conversation = new StringBuilder(); + // A tool result from an earlier turn says nothing about which agent should answer now. + var conversationDialogs = dialogs + .Where(x => !x.ExcludeFromContext) + .Where(x => x.Role != AgentRole.Function || x.MessageId == Context.MessageId) + .TakeLast(maxDialogCount) + .ToList(); foreach (var dialog in conversationDialogs) { - var role = dialog.Role; - if (role != AgentRole.User) + var agent = dialog.Role == AgentRole.User ? null : await agentService.GetAgent(dialog.CurrentAgentId); + var name = agent?.Name ?? dialog.Role; + + if (dialog.Role == AgentRole.User) { - var agent = await agentService.GetAgent(dialog.CurrentAgentId); - role = agent.Name; + // What the user said can arrive as a postback payload rather than as text + conversation.Append($"{name}: {dialog.LlmContent}\r\n"); } - - if (role == AgentRole.User) + else if (dialog.Role == AgentRole.Function) { - conversation += $"{role}: {dialog.Payload ?? dialog.Content}\r\n"; + // A tool result is not something the agent said, so name the call it answers + conversation.Append($"{name}: Call function {dialog.FunctionName}({dialog.FunctionArgs}) => {dialog.Content}\r\n"); } else { // Assistant reply doesn't need help with payload - conversation += $"{role}: {dialog.Content}\r\n"; + conversation.Append($"{name}: {dialog.Content}\r\n"); } } - return conversation; + return conversation.ToString(); } } diff --git a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeAgent.cs b/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeAgent.cs index 39be4646d..35f3049b3 100644 --- a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeAgent.cs +++ b/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeAgent.cs @@ -133,11 +133,10 @@ private async Task InvokeFunction( } else { - // The function wrote the reply itself. The call is still worth a record, but kept out - // of context, since the assistant message that follows carries the same text. - var record = RoleDialogModel.From(message, role: AgentRole.Function); - record.ExcludeFromContext = true; - await Persist(record); + // The function wrote the reply itself, and the assistant message that follows repeats + // its text. The call is recorded anyway, and stays in context: it is the only thing + // telling a later turn that this function already ran. + await Persist(RoleDialogModel.From(message, role: AgentRole.Function)); var msg = RoleDialogModel.From(message, role: AgentRole.Assistant, diff --git a/tests/BotSharp.Core.UnitTests/Conversations/ToolResultTrimHookTests.cs b/tests/BotSharp.Core.UnitTests/Conversations/ToolResultTrimHookTests.cs new file mode 100644 index 000000000..78f0c6362 --- /dev/null +++ b/tests/BotSharp.Core.UnitTests/Conversations/ToolResultTrimHookTests.cs @@ -0,0 +1,233 @@ +using BotSharp.Abstraction.Agents.Enums; +using BotSharp.Abstraction.Conversations.Models; +using BotSharp.Abstraction.Conversations.Settings; +using BotSharp.Abstraction.Routing; +using BotSharp.Core.Conversations.Hooks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace BotSharp.Core.UnitTests.Conversations; + +/// +/// A tool result is worth its tokens to the turn that asked for it and rarely worth them again. +/// This hook is where that is acted on, and these tests pin the two things it must never do: +/// touch what the current turn is about to read, and lose the call itself -- an agent that cannot +/// see it already ran a function runs it again. +/// +public class ToolResultTrimHookTests +{ + private const string OldTurn = "turn-1"; + private const string MiddleTurn = "turn-2"; + private const string RecentTurn = "turn-3"; + private const string CurrentTurn = "turn-4"; + + private static ToolResultTrimHook BuildHook(ConversationSetting settings, string currentMessageId) + { + var context = new Mock(); + context.SetupGet(x => x.MessageId).Returns(currentMessageId); + + var services = new ServiceCollection(); + services.AddSingleton(context.Object); + + return new ToolResultTrimHook( + services.BuildServiceProvider(), + settings, + NullLogger.Instance); + } + + private static ConversationSetting Settings(bool enable = true, int keepTurns = 2, int maxLength = 500) + => new() + { + ToolResultTrim = new ToolResultTrimSetting + { + Enable = enable, + KeepTurns = keepTurns, + MaxLength = maxLength + } + }; + + private static RoleDialogModel Tool(string messageId, string content, string function = "read_work_order") + => new(AgentRole.Function, content) + { + MessageId = messageId, + FunctionName = function, + ToolCallId = $"call_{messageId}", + FunctionArgs = "{\"wo_num\":\"A123\"}" + }; + + private static string Rendered(int length) + => "WO Num: A1234567\r\n" + new string('x', length - 18); + + private static string Json(int length) + => "{\"wo_num\":\"A1234567\",\"pad\":\"" + new string('x', length - 30) + "\"}"; + + [Fact] + public async Task Leaves_the_turn_that_is_running_untouched() + { + var dialogs = new List + { + Tool(OldTurn, Rendered(4000)), + Tool(MiddleTurn, Rendered(4000)), + Tool(RecentTurn, Rendered(4000)), + Tool(CurrentTurn, Rendered(4000)) + }; + + // keepTurns 0, so only "this is the turn in flight" can protect the last one. + await BuildHook(Settings(keepTurns: 0), CurrentTurn).OnDialogsLoaded(dialogs); + + Assert.Equal(4000, dialogs[3].Content.Length); + Assert.All(dialogs.Take(3), x => Assert.True(x.Content.Length < 4000)); + } + + [Fact] + public async Task Keeps_the_most_recent_turns_whole_and_shortens_what_is_older() + { + var dialogs = new List + { + new(AgentRole.User, "how is my work order?") { MessageId = OldTurn }, + Tool(OldTurn, Rendered(4000)), + Tool(MiddleTurn, Rendered(4000)), + Tool(RecentTurn, Rendered(4000)) + }; + + await BuildHook(Settings(keepTurns: 2), CurrentTurn).OnDialogsLoaded(dialogs); + + Assert.True(dialogs[1].Content.Length < 4000); + Assert.Equal(4000, dialogs[2].Content.Length); + Assert.Equal(4000, dialogs[3].Content.Length); + } + + [Fact] + public async Task Keeps_the_call_even_when_the_result_goes() + { + var dialogs = new List { Tool(OldTurn, Json(4000)) }; + + await BuildHook(Settings(keepTurns: 0), CurrentTurn).OnDialogsLoaded(dialogs); + + var stored = dialogs[0]; + Assert.Equal(AgentRole.Function, stored.Role); + Assert.Equal("read_work_order", stored.FunctionName); + Assert.Equal($"call_{OldTurn}", stored.ToolCallId); + Assert.Equal("{\"wo_num\":\"A123\"}", stored.FunctionArgs); + } + + [Fact] + public async Task Replaces_a_structured_result_rather_than_cutting_it_in_half() + { + var dialogs = new List { Tool(OldTurn, Json(4000)) }; + + await BuildHook(Settings(keepTurns: 0), CurrentTurn).OnDialogsLoaded(dialogs); + + // Half a JSON document reads as a whole one to a model that cannot see where it was cut. + Assert.DoesNotContain("{\"wo_num\"", dialogs[0].Content); + Assert.Equal("[4000 chars omitted; call again for detail]", dialogs[0].Content); + } + + [Fact] + public async Task Keeps_the_head_of_a_rendered_result_and_says_what_was_dropped() + { + var dialogs = new List { Tool(OldTurn, Rendered(4000)) }; + + await BuildHook(Settings(keepTurns: 0, maxLength: 500), CurrentTurn).OnDialogsLoaded(dialogs); + + Assert.StartsWith("WO Num: A1234567", dialogs[0].Content); + Assert.Contains("3500 chars omitted", dialogs[0].Content); + } + + [Fact] + public async Task Keeps_the_sentence_that_introduces_a_document_and_drops_the_document() + { + // What an MCP server answers with is several text blocks joined together, so a document + // can sit behind a line of prose. The prose is what a later turn can still use. + var dialogs = new List + { + Tool(OldTurn, "Found 3 work orders:\r\n" + Json(4000)) + }; + + await BuildHook(Settings(keepTurns: 0, maxLength: 500), CurrentTurn).OnDialogsLoaded(dialogs); + + Assert.StartsWith("Found 3 work orders:", dialogs[0].Content); + Assert.DoesNotContain("\"wo_num\"", dialogs[0].Content); + Assert.Contains("chars omitted", dialogs[0].Content); + } + + [Fact] + public async Task Keeps_prose_whose_document_begins_past_the_cut() + { + var dialogs = new List + { + Tool(OldTurn, Rendered(900) + Json(3000)) + }; + + await BuildHook(Settings(keepTurns: 0, maxLength: 500), CurrentTurn).OnDialogsLoaded(dialogs); + + Assert.StartsWith("WO Num: A1234567", dialogs[0].Content); + Assert.DoesNotContain("\"wo_num\"", dialogs[0].Content); + } + + [Theory] + [InlineData("{\"wo_num\": \"A123\", ")] // object + [InlineData("[{\"wo_num\": \"A123\"}, ")] // array of objects + [InlineData("[\"A123\", \"A456\", ")] // array of strings + [InlineData("[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ")] // array of numbers + public async Task Treats_an_opening_value_as_structured(string opening) + { + var dialogs = new List { Tool(OldTurn, opening + new string('x', 4000)) }; + + await BuildHook(Settings(keepTurns: 0, maxLength: 500), CurrentTurn).OnDialogsLoaded(dialogs); + + Assert.Matches(@"^\[\d+ chars omitted; call again for detail\]$", dialogs[0].Content); + } + + [Fact] + public async Task Does_not_mistake_a_brace_in_a_sentence_for_a_document() + { + // Prose is allowed to contain a brace. Replacing the whole result over one would throw + // away a head that was perfectly safe to keep. + var dialogs = new List + { + Tool(OldTurn, "Use the {name} placeholder in the template. " + new string('x', 4000)) + }; + + await BuildHook(Settings(keepTurns: 0, maxLength: 500), CurrentTurn).OnDialogsLoaded(dialogs); + + Assert.StartsWith("Use the {name} placeholder", dialogs[0].Content); + Assert.Contains("chars omitted", dialogs[0].Content); + } + + [Fact] + public async Task Leaves_a_short_result_alone() + { + var dialogs = new List { Tool(OldTurn, Rendered(400)) }; + + await BuildHook(Settings(keepTurns: 0), CurrentTurn).OnDialogsLoaded(dialogs); + + Assert.Equal(400, dialogs[0].Content.Length); + } + + [Fact] + public async Task Does_nothing_at_all_when_switched_off() + { + var dialogs = new List { Tool(OldTurn, Json(4000)) }; + + await BuildHook(Settings(enable: false, keepTurns: 0), CurrentTurn).OnDialogsLoaded(dialogs); + + Assert.Equal(4000, dialogs[0].Content.Length); + } + + [Fact] + public async Task Leaves_every_role_but_function_alone() + { + var dialogs = new List + { + new(AgentRole.User, Rendered(4000)) { MessageId = OldTurn }, + new(AgentRole.Assistant, Rendered(4000)) { MessageId = OldTurn } + }; + + await BuildHook(Settings(keepTurns: 0), CurrentTurn).OnDialogsLoaded(dialogs); + + Assert.All(dialogs, x => Assert.Equal(4000, x.Content.Length)); + } +}