44namespace BotSharp . Core . Conversations . Hooks ;
55
66/// <summary>
7- /// Shortens the tool results an older turn replays into the prompt.
7+ /// Shortens the tool results an older turn replays into the prompt. It runs where history is
8+ /// loaded, so the turn that ran a tool still reads its result whole. Only <c>Content</c> is
9+ /// touched: the call stays intact, and storage keeps the full text.
810/// </summary>
9- /// <remarks>
10- /// It runs where history is loaded, which is exactly the turn boundary: the messages a turn
11- /// produces are appended to the list in memory and never pass through here, so the turn that ran
12- /// a tool always reads its result whole. Only <c>Content</c> is touched -- the call itself stays
13- /// intact, so a later turn can still tell that the function already ran, and storage keeps the
14- /// full text either way.
15- /// </remarks>
1611public class ToolResultTrimHook : ConversationHookBase
1712{
18- /// <summary>The opening of a structured document: a brace or bracket that starts one, not a word in prose.</summary>
13+ /// <summary>Stands in for a result the assistant message of the same turn already carries.</summary>
14+ private const string RepliedToUser = "[replied to the user]" ;
15+
16+ /// <summary>A brace or bracket that opens a value, as opposed to one in a sentence.</summary>
1917 private static readonly Regex StructuredStart = new ( @"[{\[]\s*[""{\[\d-]" , RegexOptions . Compiled ) ;
2018
2119 private readonly IServiceProvider _services ;
@@ -42,9 +40,8 @@ public override Task OnDialogsLoaded(List<RoleDialogModel> dialogs)
4240 return base . OnDialogsLoaded ( dialogs ) ;
4341 }
4442
45- // A turn is a message id: everything a turn derives carries the id of the message that
46- // started it. The current one is skipped as well, because a function that reloads the
47- // history mid-turn would otherwise be handed a shortened copy of what it just produced.
43+ // A turn is a message id. The current one is left alone for a function that reloads the
44+ // history mid-turn.
4845 var currentMessageId = _services . GetRequiredService < IRoutingContext > ( ) . MessageId ;
4946 var recentTurns = dialogs
5047 . Select ( x => x . MessageId )
@@ -53,21 +50,39 @@ public override Task OnDialogsLoaded(List<RoleDialogModel> dialogs)
5350 . TakeLast ( Math . Max ( setting . KeepTurns , 0 ) )
5451 . ToHashSet ( ) ;
5552
53+ var replies = dialogs
54+ . Where ( x => x . Role == AgentRole . Assistant && ! string . IsNullOrEmpty ( x . Content ) )
55+ . Select ( x => ( x . MessageId , x . Content ) )
56+ . ToHashSet ( ) ;
57+
5658 var trimmed = 0 ;
5759 var saved = 0 ;
5860
5961 foreach ( var dialog in dialogs )
6062 {
6163 if ( dialog . Role != AgentRole . Function
6264 || dialog . MessageId == currentMessageId
63- || recentTurns . Contains ( dialog . MessageId )
64- || ( dialog . Content ? . Length ?? 0 ) <= setting . MaxLength )
65+ || string . IsNullOrEmpty ( dialog . Content ) )
6566 {
6667 continue ;
6768 }
6869
6970 var before = dialog . Content . Length ;
70- dialog . Content = Shorten ( dialog , setting . MaxLength ) ;
71+
72+ if ( replies . Contains ( ( dialog . MessageId , dialog . Content ) ) )
73+ {
74+ // Repeating what the assistant message says word for word teaches the model to
75+ // answer by echoing tool output. Nothing is lost, so recent turns are no exception.
76+ dialog . Content = RepliedToUser ;
77+ }
78+ else if ( ! recentTurns . Contains ( dialog . MessageId ) && before > setting . MaxLength )
79+ {
80+ dialog . Content = Shorten ( dialog , setting . MaxLength ) ;
81+ }
82+ else
83+ {
84+ continue ;
85+ }
7186
7287 trimmed ++ ;
7388 saved += before - dialog . Content . Length ;
@@ -76,34 +91,25 @@ public override Task OnDialogsLoaded(List<RoleDialogModel> dialogs)
7691 if ( trimmed > 0 )
7792 {
7893 _logger . LogInformation (
79- "[ToolResultTrim] {Count} tool result(s) older than {KeepTurns} turn(s) shortened, {Saved} characters kept out of the prompt." ,
80- trimmed , setting . KeepTurns , saved ) ;
94+ "[ToolResultTrim] {Count} tool result(s) shortened, {Saved} characters kept out of the prompt." ,
95+ trimmed , saved ) ;
8196 }
8297
8398 return base . OnDialogsLoaded ( dialogs ) ;
8499 }
85100
86101 /// <summary>
87- /// Keeps the head of a rendered result, whose useful part comes first, and replaces a
88- /// structured one outright: half a JSON document is not something a model can read, and it
89- /// cannot tell that the half it got is not the whole .
102+ /// Cuts wherever comes first, a structured value or the length cap. Half a document is
103+ /// something a model can neither read nor tell is incomplete, so one is replaced outright,
104+ /// while the sentence that introduced it is worth keeping .
90105 /// </summary>
91- /// <remarks>
92- /// The cut goes wherever comes first: where a structured value begins, or the length cap. A
93- /// tool is free to answer with a sentence and then a document -- an MCP server returning
94- /// several text blocks does exactly that -- and cutting inside the document would leave a
95- /// model something it cannot read and cannot tell is incomplete, while cutting in front of it
96- /// keeps the sentence that introduced it. A lone brace in prose is not a document: what is
97- /// looked for is a brace or bracket that opens a value.
98- /// </remarks>
99106 private static string Shorten ( RoleDialogModel dialog , int maxLength )
100107 {
101108 var content = dialog . Content ;
102109 var structure = StructuredStart . Match ( content ) ;
103110 var cut = structure . Success ? Math . Min ( structure . Index , maxLength ) : maxLength ;
104111
105- // The call itself sits in the message right before this one, so the note does not name it
106- // again -- what a later turn cannot work out on its own is that something was dropped.
112+ // The call is named in the message right before this one, so the note does not repeat it.
107113 if ( cut == 0 )
108114 {
109115 return $ "[{ content . Length } chars omitted; call again for detail]";
0 commit comments