Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}

/// <summary>
/// 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.
/// </summary>
public class ToolResultTrimSetting
{
public bool Enable { get; set; } = true;

/// <summary>How many of the most recent turns keep their tool results verbatim.</summary>
public int KeepTurns { get; set; } = 2;

/// <summary>A result longer than this, and older than <see cref="KeepTurns"/>, is shortened.</summary>
public int MaxLength { get; set; } = 500;
}

public class CleanConversationSetting
{
public bool Enable { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -72,6 +73,8 @@ public void RegisterDI(IServiceCollection services, IConfiguration config)
services.AddScoped<ITokenStatistics, TokenStatistics>();

services.AddScoped<IAgentUtilityHook, WebSearchUtilityHook>();

services.AddScoped<IConversationHook, ToolResultTrimHook>();
}

public bool AttachMenu(List<PluginMenuDef> menu)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using System.Text.RegularExpressions;
using BotSharp.Abstraction.Conversations.Settings;

namespace BotSharp.Core.Conversations.Hooks;

/// <summary>
/// Shortens the tool results an older turn replays into the prompt.
/// </summary>
/// <remarks>
/// 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 <c>Content</c> 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.
/// </remarks>
public class ToolResultTrimHook : ConversationHookBase
{
/// <summary>The opening of a structured document: a brace or bracket that starts one, not a word in prose.</summary>
private static readonly Regex StructuredStart = new(@"[{\[]\s*[""{\[\d-]", RegexOptions.Compiled);

private readonly IServiceProvider _services;
private readonly ConversationSetting _settings;
private readonly ILogger<ToolResultTrimHook> _logger;

public ToolResultTrimHook(
IServiceProvider services,
ConversationSetting settings,
ILogger<ToolResultTrimHook> logger)
{
_services = services;
_settings = settings;
_logger = logger;
}

public override string SelfId => string.Empty;

public override Task OnDialogsLoaded(List<RoleDialogModel> 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<IRoutingContext>().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);
}

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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]";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,35 @@ public partial class RoutingService
public async Task<string> GetConversationContent(List<RoleDialogModel> dialogs, int maxDialogCount = 100)
{
var agentService = _services.GetRequiredService<IAgentService>();
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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,10 @@ private async Task<bool> 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,
Expand Down
Loading
Loading