From 9ec48d0e7634f0a7f716d1e5db9f4904db25477d Mon Sep 17 00:00:00 2001 From: hchen Date: Mon, 3 Aug 2026 20:34:30 -0500 Subject: [PATCH] Add ToolProgressIndicator for real-time tool progress Introduced ToolProgressIndicator to relay progress updates from MCP tool executions to the chat interface. Progress is pushed via MessageHub during long-running tool calls, improving user feedback for tools that report incremental progress. --- .../Routing/Executor/MCPToolExecutor.cs | 103 +++++++++++++++++- 1 file changed, 101 insertions(+), 2 deletions(-) diff --git a/src/Infrastructure/BotSharp.Core/Routing/Executor/MCPToolExecutor.cs b/src/Infrastructure/BotSharp.Core/Routing/Executor/MCPToolExecutor.cs index e402de841..08be74599 100644 --- a/src/Infrastructure/BotSharp.Core/Routing/Executor/MCPToolExecutor.cs +++ b/src/Infrastructure/BotSharp.Core/Routing/Executor/MCPToolExecutor.cs @@ -1,5 +1,7 @@ using BotSharp.Abstraction.Routing.Executor; using BotSharp.Core.MCP.Managers; +using BotSharp.Core.MessageHub; +using ModelContextProtocol; using ModelContextProtocol.Protocol; namespace BotSharp.Core.Routing.Executor; @@ -33,8 +35,12 @@ public async Task ExecuteAsync(RoleDialogModel message) return false; } - // Call the tool through mcpdotnet - var result = await client.CallToolAsync(_functionName, !argDict.IsNullOrEmpty() ? argDict : []); + // Call the tool through mcpdotnet, relaying whatever progress it reports along the + // way — see ToolProgressIndicator for why the reporter has to be supplied here. + var result = await client.CallToolAsync( + _functionName, + !argDict.IsNullOrEmpty() ? argDict : [], + progress: ToolProgressIndicator.For(_services, message)); // Extract the text content from the result var json = string.Join("\n", result.Content.Where(c => c is TextContentBlock).Select(c => ((TextContentBlock)c).Text)); @@ -55,6 +61,99 @@ public Task GetIndicatorAsync(RoleDialogModel message) return Task.FromResult(message.Indication ?? string.Empty); } + /// + /// Turns a tool's notifications/progress into indications, so a long call can say what + /// it is doing while it does it. + /// + /// WHY THIS EXISTS. RoutingService pushes one indication when a function starts, and for a + /// tool that returns in a second that is the whole story. An MCP tool driving a browser takes + /// minutes, and that single line — "Working out the steps" — was all the chat had to show for + /// the entire run: no way to tell a task making progress from one that had wedged. + /// + /// + /// Supplying the reporter is also what MAKES a server report. The SDK only attaches a + /// progressToken to the request when this is non-null, and a server with no token to + /// answer has nowhere to send notifications — computer-autoplay's await_web_task + /// returns early from its own reporter for exactly that reason. So the argument is not an + /// optimisation of an existing stream; it is what opens it. + /// + /// + /// Nothing is required of a server that does not report progress: no notifications arrive, + /// is never called, and the call behaves as it did before. + /// + /// + private sealed class ToolProgressIndicator : IProgress + { + private readonly MessageHub> _hub; + private readonly string _conversationId; + private readonly RoleDialogModel _message; + + private ToolProgressIndicator( + MessageHub> hub, + string conversationId, + RoleDialogModel message) + { + _hub = hub; + _conversationId = conversationId; + _message = message; + } + + /// + /// A reporter for this call, or null when there is no conversation to report into — a + /// tool invoked outside one, from a task or a test. Null is the right answer there rather + /// than a reporter that drops everything: it also tells the server not to bother sending. + /// + /// The conversation id is read HERE, on the thread that starts the call, and captured. + /// runs on whichever thread the MCP transport is reading on, and + /// resolving a scoped service from there to ask again would be a race for a value that + /// cannot change during the call. + /// + /// + public static ToolProgressIndicator? For(IServiceProvider services, RoleDialogModel message) + { + var conversationId = services.GetRequiredService().ConversationId; + if (string.IsNullOrWhiteSpace(conversationId)) + { + return null; + } + + var hub = services.GetRequiredService>>(); + return new ToolProgressIndicator(hub, conversationId, message); + } + + /// + /// Pushes one indication per reported step. + /// + /// Deliberately synchronous, which is why this is a hand-written IProgress rather than a + /// : Progress<T> queues every report to the thread pool + /// independently, so two steps reported together can arrive out of order — and the one + /// that arrives last is the one left on screen. Pushing inline keeps the order the server + /// sent them in. + /// + /// + public void Report(ProgressNotificationValue value) + { + // A bare count with no message is a progress BAR's input, not a sentence, and the + // chat has nowhere to put it. Servers that only send numbers are simply not relayed. + if (string.IsNullOrWhiteSpace(value.Message)) + { + return; + } + + // Cloned: this is pushed to observers that read it, and the function's own message is + // still being used by the call in flight. Its indication is not ours to overwrite. + var indication = RoleDialogModel.From(_message); + indication.Indication = value.Message; + + _hub.Push(new() + { + EventName = ChatEvent.OnIndicationReceived, + Data = indication, + RefId = _conversationId + }); + } + } + private static Dictionary JsonToDictionary(string? json) {