From 96aef766bffd288b0507bf6a450d5ce141d43392 Mon Sep 17 00:00:00 2001 From: Jicheng Lu Date: Tue, 18 Aug 2026 21:06:29 -0500 Subject: [PATCH 1/2] Move inbound translation from RoutingService to ConversationService.SendMessage TranslateInboundMessage was called from both InstructDirect and InstructLoop, leaving the two routing paths to stay in sync by hand. SendMessage is the only caller of either, so run it once there, ahead of the conversation hook loop. Running before the hooks means they now evaluate English content, which matters for RoutingConversationHook: it embeds message.Content and classifies intent against an English-trained model. It also means a hook can overwrite Content after SecondaryContent has been set to the user's original text. Consumers render SecondaryContent in preference to Content, so a rate-limited or intent-templated reply would be replaced by the user's own message on its way back to them. Clear SecondaryContent when a hook rewrites Content, gated on whether translation actually ran so we only ever clear a value we set ourselves. This matches RoleDialogModel.From, which already drops SecondaryContent when it replaces Content. Co-Authored-By: Claude Opus 5 (1M context) --- .../ConversationService.SendMessage.cs | 14 +++++++++ .../ConversationService.Translation.cs} | 31 +++++++++++-------- .../Routing/RoutingService.InstructLoop.cs | 3 -- .../BotSharp.Core/Routing/RoutingService.cs | 4 --- 4 files changed, 32 insertions(+), 20 deletions(-) rename src/Infrastructure/BotSharp.Core/{Routing/RoutingService.Translation.cs => Conversations/Services/ConversationService.Translation.cs} (62%) diff --git a/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.SendMessage.cs b/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.SendMessage.cs index c8775ea33..dc92e3982 100644 --- a/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.SendMessage.cs +++ b/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.SendMessage.cs @@ -43,6 +43,11 @@ public async Task SendMessage(string agentId, message.Payload = replyMessage.Payload; } + // Handle multi-language for input. Runs ahead of the hooks so they evaluate English content, + // and covers both routing paths from a single place. + var isTranslated = await TranslateInboundMessage(agent, message); + var inboundContent = message.Content; + var hooks = _services.GetHooksOrderByPriority(message.CurrentAgentId); foreach (var hook in hooks) { @@ -67,6 +72,15 @@ public async Task SendMessage(string agentId, } } + // A hook may swap in its own reply (rate limit, intent template). The SecondaryContent set above + // still holds the user's original text, and consumers render SecondaryContent in preference to + // Content, so drop it - otherwise the user is shown their own message instead of the hook's + // reply. Gated on isTranslated so we only ever clear a SecondaryContent we set ourselves. + if (isTranslated && message.Content != inboundContent) + { + message.SecondaryContent = null; + } + if (!stopCompletion) { // Routing with reasoning diff --git a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.Translation.cs b/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.Translation.cs similarity index 62% rename from src/Infrastructure/BotSharp.Core/Routing/RoutingService.Translation.cs rename to src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.Translation.cs index 8aa297d3f..dedcc5895 100644 --- a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.Translation.cs +++ b/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.Translation.cs @@ -1,42 +1,45 @@ using BotSharp.Abstraction.Infrastructures.Enums; -namespace BotSharp.Core.Routing; +namespace BotSharp.Core.Conversations.Services; -public partial class RoutingService +public partial class ConversationService { private const string TranslationPromptName = "translation_prompt"; /// - /// Normalize an inbound user message to English so routing rules, agent instructions and - /// function arguments are always evaluated in English. The user's original text is kept in - /// SecondaryContent. Shared by InstructLoop and InstructDirect so every entry point into the - /// routing service behaves the same way. + /// Normalize an inbound user message to English so conversation hooks, routing rules, agent + /// instructions and function arguments are always evaluated in English. The user's original + /// text is kept in SecondaryContent. Runs once in SendMessage ahead of the hook loop, so both + /// routing paths and every hook observe the same normalized message. /// - private async Task TranslateInboundMessage(Agent agent, RoleDialogModel message) + /// + /// True when the message was translated and SecondaryContent now holds the user's original text. + /// Callers rely on this to tell apart a SecondaryContent this method authored from one it never + /// touched, so it must stay false on every early return. + /// + private async Task TranslateInboundMessage(Agent agent, RoleDialogModel message) { var agentSettings = _services.GetRequiredService(); if (!agentSettings.EnableTranslator) { - return; + return false; } - var states = _services.GetRequiredService(); - // The caller supplies the language through the request states; the server does not detect it. // TranslationService back-fills StateConst.LANGUAGE only when the state is absent, which cannot // happen here - an absent state defaults to English and returns early. That back-fill serves // the /translate endpoint instead. // Unknown is excluded to stay in sync with TranslationResponseHook: it means the language has // not been resolved, so translating would only paraphrase the user's own words. - var language = states.GetState(StateConst.LANGUAGE, LanguageType.ENGLISH); + var language = _state.GetState(StateConst.LANGUAGE, LanguageType.ENGLISH); if (language == LanguageType.ENGLISH || language == LanguageType.UNKNOWN) { - return; + return false; } // TranslationService reads the prompt template off the agent it is handed, and only the // AI Assistant defines it. Fall back to that agent when the executing one has no template, - // which is the normal case for the task agents reaching us through InstructDirect. + // which is the normal case for the task agents handled by InstructDirect. var host = agent; if (host?.Templates?.Any(x => x.Name == TranslationPromptName) != true) { @@ -49,5 +52,7 @@ private async Task TranslateInboundMessage(Agent agent, RoleDialogModel message) message.Content = await translator.Translate(host, message.MessageId, message.Content, language: LanguageType.ENGLISH, clone: false); + + return true; } } diff --git a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InstructLoop.cs b/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InstructLoop.cs index 63d588a45..5d3365bda 100644 --- a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InstructLoop.cs +++ b/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InstructLoop.cs @@ -23,9 +23,6 @@ public async Task InstructLoop(Agent agent, RoleDialogModel mes await _context.Push(_router.Id); - // Handle multi-language for input - await TranslateInboundMessage(_router, message); - dialogs.Add(message); Context.SetDialogs(dialogs); await storage.Append(convService.ConversationId, message); diff --git a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.cs b/src/Infrastructure/BotSharp.Core/Routing/RoutingService.cs index 73569f756..00b64f3ef 100644 --- a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.cs +++ b/src/Infrastructure/BotSharp.Core/Routing/RoutingService.cs @@ -31,10 +31,6 @@ public async Task InstructDirect(Agent agent, RoleDialogModel m var conv = _services.GetRequiredService(); var storage = _services.GetRequiredService(); - // Must run before the message is persisted so the stored record matches InstructLoop: - // Content in English, the user's original text in SecondaryContent. - await TranslateInboundMessage(agent, message); - await storage.Append(conv.ConversationId, message); dialogs.Add(message); From 994b2fe231a3305422815732f755fdc1ccf83d9c Mon Sep 17 00:00:00 2001 From: Jicheng Lu Date: Tue, 18 Aug 2026 22:38:57 -0500 Subject: [PATCH 2/2] refine --- .../Services/ConversationService.SendMessage.cs | 12 +----------- .../Hooks/RateLimitConversationHook.cs | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.SendMessage.cs b/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.SendMessage.cs index dc92e3982..12bcff992 100644 --- a/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.SendMessage.cs +++ b/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.SendMessage.cs @@ -45,8 +45,7 @@ public async Task SendMessage(string agentId, // Handle multi-language for input. Runs ahead of the hooks so they evaluate English content, // and covers both routing paths from a single place. - var isTranslated = await TranslateInboundMessage(agent, message); - var inboundContent = message.Content; + await TranslateInboundMessage(agent, message); var hooks = _services.GetHooksOrderByPriority(message.CurrentAgentId); foreach (var hook in hooks) @@ -72,15 +71,6 @@ public async Task SendMessage(string agentId, } } - // A hook may swap in its own reply (rate limit, intent template). The SecondaryContent set above - // still holds the user's original text, and consumers render SecondaryContent in preference to - // Content, so drop it - otherwise the user is shown their own message instead of the hook's - // reply. Gated on isTranslated so we only ever clear a SecondaryContent we set ourselves. - if (isTranslated && message.Content != inboundContent) - { - message.SecondaryContent = null; - } - if (!stopCompletion) { // Routing with reasoning diff --git a/src/Infrastructure/BotSharp.Logger/Hooks/RateLimitConversationHook.cs b/src/Infrastructure/BotSharp.Logger/Hooks/RateLimitConversationHook.cs index 02cafbd08..d1d95d31f 100644 --- a/src/Infrastructure/BotSharp.Logger/Hooks/RateLimitConversationHook.cs +++ b/src/Infrastructure/BotSharp.Logger/Hooks/RateLimitConversationHook.cs @@ -23,7 +23,9 @@ public override async Task OnMessageReceived(RoleDialogModel message) { var settings = _services.GetRequiredService(); var states = _services.GetRequiredService(); - + var storage = _services.GetRequiredService(); + + var convId = states.GetConversationId(); var rateLimit = settings.RateLimit; var channel = states.GetState("channel"); @@ -31,8 +33,10 @@ public override async Task OnMessageReceived(RoleDialogModel message) var charCount = message.Content.Length; if (charCount > rateLimit.MaxInputLengthPerRequest) { + await storage.Append(convId, message); message.Content = $"The number of characters in your message exceeds the system maximum of {rateLimit.MaxInputLengthPerRequest}"; message.StopCompletion = true; + ClearMessage(message); return; } @@ -50,8 +54,10 @@ public override async Task OnMessageReceived(RoleDialogModel message) var seconds = (DateTime.UtcNow - userSents.First().CreatedAt).TotalSeconds; if (seconds < rateLimit.MinTimeSecondsBetweenMessages) { + await storage.Append(convId, message); message.Content = "Your message sending frequency exceeds the frequency specified by the system. Please try again later."; message.StopCompletion = true; + ClearMessage(message); return; } } @@ -69,10 +75,19 @@ public override async Task OnMessageReceived(RoleDialogModel message) if (results.Count > rateLimit.MaxConversationPerDay) { + await storage.Append(convId, message); message.Content = $"The number of conversations you have exceeds the system maximum of {rateLimit.MaxConversationPerDay}"; message.StopCompletion = true; + ClearMessage(message); return; } } } + + private void ClearMessage(RoleDialogModel message) + { + message.SecondaryContent = null; + message.RichContent = null; + message.SecondaryRichContent = null; + } }