diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index efb0249..0126edd 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -656,6 +656,12 @@ pub struct AgentProcessResult { #[async_trait] pub trait EmittedMessageHandler: Send + Sync + 'static { async fn handle(&self, message: ChatMessage); + + /// Handle a tool result message with optional execution timing. + /// Default implementation delegates to `handle()`, ignoring timing. + async fn handle_tool_result(&self, message: ChatMessage, _duration_ms: Option) { + self.handle(message).await; + } } /// 装饰器:在内部 emitter 广播前,先将消息持久化到 DB @@ -688,6 +694,17 @@ impl EmittedMessageHandler for PersistingEmittedMessag } self.inner.handle(message).await; } + + async fn handle_tool_result(&self, message: ChatMessage, duration_ms: Option) { + // Persist the ChatMessage first (no duration field, same as before) + if let Err(e) = self.conversation_repository + .append_message_with_topic(&self.session_id, self.topic_id.as_deref(), &message) + { + tracing::error!(error = %e, session_id = %self.session_id, + "Failed to persist emitted message"); + } + self.inner.handle_tool_result(message, duration_ms).await; + } } pub trait SkillProvider: Send + Sync + 'static { @@ -1011,7 +1028,8 @@ impl AgentLoop { ); messages.push(tool_message.clone()); emitted_messages.push(tool_message.clone()); - self.emit_live_tool_call_message(tool_message).await; + let duration_ms = Some(result.duration.as_millis() as u64); + self.emit_tool_result(tool_message, duration_ms).await; } LoopDetectionResult::Ok => { let tool_message = ChatMessage::tool_with_state( @@ -1026,7 +1044,8 @@ impl AgentLoop { ); messages.push(tool_message.clone()); emitted_messages.push(tool_message.clone()); - self.emit_live_tool_call_message(tool_message).await; + let duration_ms = Some(result.duration.as_millis() as u64); + self.emit_tool_result(tool_message, duration_ms).await; } } } @@ -1156,6 +1175,12 @@ impl AgentLoop { } } + async fn emit_tool_result(&self, message: ChatMessage, duration_ms: Option) { + if let Some(handler) = &self.emitted_message_handler { + handler.handle_tool_result(message, duration_ms).await; + } + } + /// Determine whether to execute tools in parallel or sequentially. /// /// Returns true if: diff --git a/src/gateway/session.rs b/src/gateway/session.rs index 0f07325..c35ab76 100644 --- a/src/gateway/session.rs +++ b/src/gateway/session.rs @@ -86,6 +86,25 @@ impl EmittedMessageHandler for BusToolCallEmitter { } } } + + async fn handle_tool_result(&self, message: ChatMessage, duration_ms: Option) { + let mut metadata = self.metadata.clone(); + if let Some(ms) = duration_ms { + metadata.insert("tool_duration_ms".to_string(), ms.to_string()); + } + for outbound in OutboundMessage::from_chat_message( + &self.channel_name, + &self.chat_id, + None, // session_id + None, + &metadata, + &message, + ) { + if let Err(error) = self.bus.publish_outbound(outbound).await { + tracing::error!(error = %error, channel = %self.channel_name, chat_id = %self.chat_id, "Failed to publish live outbound tool call"); + } + } + } } impl Session { diff --git a/src/gateway/ws.rs b/src/gateway/ws.rs index b1f7d1b..1ce674e 100644 --- a/src/gateway/ws.rs +++ b/src/gateway/ws.rs @@ -662,6 +662,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option Some(WsOutbound::ToolPending { id: msg.id.clone(), diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 4e89d50..135fcfb 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -104,6 +104,8 @@ pub enum WsOutbound { role: String, #[serde(default, skip_serializing_if = "Option::is_none")] subagent_task_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + duration_ms: Option, }, #[serde(rename = "tool_pending")] ToolPending { diff --git a/src/protocol/ws_adapter.rs b/src/protocol/ws_adapter.rs index 25475f9..c92d501 100644 --- a/src/protocol/ws_adapter.rs +++ b/src/protocol/ws_adapter.rs @@ -57,6 +57,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec vec![WsOutbound::ToolPending { id: message.id.clone(), @@ -119,6 +120,10 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve content: message.content.clone(), role: message.role.clone(), subagent_task_id: message.metadata.get("subagent_task_id").cloned(), + duration_ms: message + .metadata + .get("tool_duration_ms") + .and_then(|v| v.parse().ok()), }], OutboundEventKind::ToolPending => vec![WsOutbound::ToolPending { id: message diff --git a/src/tools/task/runtime.rs b/src/tools/task/runtime.rs index e811cb4..ec629d1 100644 --- a/src/tools/task/runtime.rs +++ b/src/tools/task/runtime.rs @@ -128,6 +128,30 @@ impl EmittedMessageHandler for SubAgentEmitter { } } } + + async fn handle_tool_result(&self, message: ChatMessage, duration_ms: Option) { + let mut metadata = self.metadata.clone(); + if let Some(ms) = duration_ms { + metadata.insert("tool_duration_ms".to_string(), ms.to_string()); + } + for outbound in OutboundMessage::from_chat_message( + &self.channel_name, + &self.chat_id, + None, + None, + &metadata, + &message, + ) { + if let Err(error) = self.bus.publish_outbound(outbound).await { + tracing::error!( + error = %error, + channel = %self.channel_name, + chat_id = %self.chat_id, + "Failed to publish live sub-agent tool call" + ); + } + } + } } impl SystemPromptProvider for StaticSystemPromptProvider { diff --git a/web/src/App.tsx b/web/src/App.tsx index 51de7cf..c8bea0f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -192,6 +192,7 @@ function App() { ...result[idx], status: 'result', resultContent: msg.content, + durationMs: msg.durationMs, } } } else if (msg.type === 'tool_pending') { diff --git a/web/src/components/Chat/MessageBubble.tsx b/web/src/components/Chat/MessageBubble.tsx index cf76695..b531709 100644 --- a/web/src/components/Chat/MessageBubble.tsx +++ b/web/src/components/Chat/MessageBubble.tsx @@ -31,6 +31,18 @@ function formatTime(timestamp: number) { }) } +function formatDuration(ms: number): string { + if (ms < 1000) { + return `${ms}ms` + } + if (ms < 60000) { + return `${(ms / 1000).toFixed(1)}s` + } + const minutes = Math.floor(ms / 60000) + const seconds = Math.floor((ms % 60000) / 1000) + return `${minutes}m ${seconds}s` +} + function AttachmentCard({ attachment }: { attachment: Attachment }) { const fileName = attachment.file_name || getFileName(attachment.path) @@ -253,6 +265,11 @@ export function MessageBubble({ message, onNavigateToSubAgent }: MessageBubblePr }`}> {taskResult ? taskStatusConfig[taskResult.status].label : statusConfig.label} + {status === 'result' && message.durationMs != null && ( + + ⏱️ {formatDuration(message.durationMs)} + + )} {hasResult && } {toolExpanded ? ( diff --git a/web/src/components/Panel/ToolPanel.tsx b/web/src/components/Panel/ToolPanel.tsx index 63d1aac..38a7559 100644 --- a/web/src/components/Panel/ToolPanel.tsx +++ b/web/src/components/Panel/ToolPanel.tsx @@ -13,6 +13,19 @@ interface ToolCallItem { arguments?: unknown resultContent: string callContent: string + durationMs?: number +} + +function formatDuration(ms: number): string { + if (ms < 1000) { + return `${ms}ms` + } + if (ms < 60000) { + return `${(ms / 1000).toFixed(1)}s` + } + const minutes = Math.floor(ms / 60000) + const seconds = Math.floor((ms % 60000) / 1000) + return `${minutes}m ${seconds}s` } function formatResultText(content: string): string { @@ -52,6 +65,7 @@ function mergeToolMessages(messages: ChatMessage[]): ToolCallItem[] { } else if (m.type === 'tool_result') { entry.status = 'result' entry.resultContent = m.content + entry.durationMs = m.durationMs } else if (m.type === 'tool_pending') { entry.status = 'pending' entry.resultContent = m.content @@ -167,6 +181,11 @@ export function ToolPanel({ messages }: ToolPanelProps) { {config.label} + {tool.status === 'result' && tool.durationMs != null && ( + + ⏱️ {formatDuration(tool.durationMs)} + + )} {isExpanded ? ( diff --git a/web/src/hooks/useChat.ts b/web/src/hooks/useChat.ts index b220a91..68cca92 100644 --- a/web/src/hooks/useChat.ts +++ b/web/src/hooks/useChat.ts @@ -147,6 +147,7 @@ export function useChat(): UseChatReturn { toolName: msg.tool_name, toolCallId: msg.tool_call_id, subagentTaskId: msg.subagent_task_id, + durationMs: msg.duration_ms, } } case 'tool_pending': { diff --git a/web/src/types/protocol.ts b/web/src/types/protocol.ts index 1015609..7e2e77a 100644 --- a/web/src/types/protocol.ts +++ b/web/src/types/protocol.ts @@ -64,6 +64,7 @@ export interface ToolResult { content: string role: string subagent_task_id?: string + duration_ms?: number } export interface ToolPending { @@ -278,6 +279,7 @@ export interface ChatMessage { resultContent?: string callContent?: string subagentTaskId?: string + durationMs?: number } /** task 工具返回的 JSON 结构 */