feat: 添加工具结果处理功能,支持执行时长记录和显示

This commit is contained in:
oudecheng 2026-06-02 16:15:05 +08:00
parent eebfe0faa5
commit f8fc0f7d0f
11 changed files with 118 additions and 2 deletions

View File

@ -656,6 +656,12 @@ pub struct AgentProcessResult {
#[async_trait] #[async_trait]
pub trait EmittedMessageHandler: Send + Sync + 'static { pub trait EmittedMessageHandler: Send + Sync + 'static {
async fn handle(&self, message: ChatMessage); 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<u64>) {
self.handle(message).await;
}
} }
/// 装饰器:在内部 emitter 广播前,先将消息持久化到 DB /// 装饰器:在内部 emitter 广播前,先将消息持久化到 DB
@ -688,6 +694,17 @@ impl<H: EmittedMessageHandler> EmittedMessageHandler for PersistingEmittedMessag
} }
self.inner.handle(message).await; self.inner.handle(message).await;
} }
async fn handle_tool_result(&self, message: ChatMessage, duration_ms: Option<u64>) {
// 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 { pub trait SkillProvider: Send + Sync + 'static {
@ -1011,7 +1028,8 @@ impl AgentLoop {
); );
messages.push(tool_message.clone()); messages.push(tool_message.clone());
emitted_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 => { LoopDetectionResult::Ok => {
let tool_message = ChatMessage::tool_with_state( let tool_message = ChatMessage::tool_with_state(
@ -1026,7 +1044,8 @@ impl AgentLoop {
); );
messages.push(tool_message.clone()); messages.push(tool_message.clone());
emitted_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<u64>) {
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. /// Determine whether to execute tools in parallel or sequentially.
/// ///
/// Returns true if: /// Returns true if:

View File

@ -86,6 +86,25 @@ impl EmittedMessageHandler for BusToolCallEmitter {
} }
} }
} }
async fn handle_tool_result(&self, message: ChatMessage, duration_ms: Option<u64>) {
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 { impl Session {

View File

@ -662,6 +662,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option<WsOutbou
content: msg.content.clone(), content: msg.content.clone(),
role: msg.role.clone(), role: msg.role.clone(),
subagent_task_id: None, subagent_task_id: None,
duration_ms: None,
}), }),
ToolMessageState::PendingUserAction => Some(WsOutbound::ToolPending { ToolMessageState::PendingUserAction => Some(WsOutbound::ToolPending {
id: msg.id.clone(), id: msg.id.clone(),

View File

@ -104,6 +104,8 @@ pub enum WsOutbound {
role: String, role: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
subagent_task_id: Option<String>, subagent_task_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
duration_ms: Option<u64>,
}, },
#[serde(rename = "tool_pending")] #[serde(rename = "tool_pending")]
ToolPending { ToolPending {

View File

@ -57,6 +57,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
content: message.content.clone(), content: message.content.clone(),
role: message.role.clone(), role: message.role.clone(),
subagent_task_id: None, subagent_task_id: None,
duration_ms: None,
}], }],
ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending { ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending {
id: message.id.clone(), id: message.id.clone(),
@ -119,6 +120,10 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
content: message.content.clone(), content: message.content.clone(),
role: message.role.clone(), role: message.role.clone(),
subagent_task_id: message.metadata.get("subagent_task_id").cloned(), 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 { OutboundEventKind::ToolPending => vec![WsOutbound::ToolPending {
id: message id: message

View File

@ -128,6 +128,30 @@ impl EmittedMessageHandler for SubAgentEmitter {
} }
} }
} }
async fn handle_tool_result(&self, message: ChatMessage, duration_ms: Option<u64>) {
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 { impl SystemPromptProvider for StaticSystemPromptProvider {

View File

@ -192,6 +192,7 @@ function App() {
...result[idx], ...result[idx],
status: 'result', status: 'result',
resultContent: msg.content, resultContent: msg.content,
durationMs: msg.durationMs,
} }
} }
} else if (msg.type === 'tool_pending') { } else if (msg.type === 'tool_pending') {

View File

@ -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 }) { function AttachmentCard({ attachment }: { attachment: Attachment }) {
const fileName = attachment.file_name || getFileName(attachment.path) 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} {taskResult ? taskStatusConfig[taskResult.status].label : statusConfig.label}
</span> </span>
{status === 'result' && message.durationMs != null && (
<span className="text-xs text-zinc-600 flex-shrink-0 tabular-nums ml-1">
{formatDuration(message.durationMs)}
</span>
)}
{hasResult && <CopyButton text={taskResult ? taskResult.output : displayContent} />} {hasResult && <CopyButton text={taskResult ? taskResult.output : displayContent} />}
<span className="ml-auto flex-shrink-0"> <span className="ml-auto flex-shrink-0">
{toolExpanded ? ( {toolExpanded ? (

View File

@ -13,6 +13,19 @@ interface ToolCallItem {
arguments?: unknown arguments?: unknown
resultContent: string resultContent: string
callContent: 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 { function formatResultText(content: string): string {
@ -52,6 +65,7 @@ function mergeToolMessages(messages: ChatMessage[]): ToolCallItem[] {
} else if (m.type === 'tool_result') { } else if (m.type === 'tool_result') {
entry.status = 'result' entry.status = 'result'
entry.resultContent = m.content entry.resultContent = m.content
entry.durationMs = m.durationMs
} else if (m.type === 'tool_pending') { } else if (m.type === 'tool_pending') {
entry.status = 'pending' entry.status = 'pending'
entry.resultContent = m.content entry.resultContent = m.content
@ -167,6 +181,11 @@ export function ToolPanel({ messages }: ToolPanelProps) {
<span className={`text-xs flex-shrink-0 transition-colors duration-500 ${config.labelClass}`}> <span className={`text-xs flex-shrink-0 transition-colors duration-500 ${config.labelClass}`}>
{config.label} {config.label}
</span> </span>
{tool.status === 'result' && tool.durationMs != null && (
<span className="text-xs text-zinc-600 flex-shrink-0 tabular-nums ml-1">
{formatDuration(tool.durationMs)}
</span>
)}
</div> </div>
<span className="flex-shrink-0 ml-2"> <span className="flex-shrink-0 ml-2">
{isExpanded ? ( {isExpanded ? (

View File

@ -147,6 +147,7 @@ export function useChat(): UseChatReturn {
toolName: msg.tool_name, toolName: msg.tool_name,
toolCallId: msg.tool_call_id, toolCallId: msg.tool_call_id,
subagentTaskId: msg.subagent_task_id, subagentTaskId: msg.subagent_task_id,
durationMs: msg.duration_ms,
} }
} }
case 'tool_pending': { case 'tool_pending': {

View File

@ -64,6 +64,7 @@ export interface ToolResult {
content: string content: string
role: string role: string
subagent_task_id?: string subagent_task_id?: string
duration_ms?: number
} }
export interface ToolPending { export interface ToolPending {
@ -278,6 +279,7 @@ export interface ChatMessage {
resultContent?: string resultContent?: string
callContent?: string callContent?: string
subagentTaskId?: string subagentTaskId?: string
durationMs?: number
} }
/** task 工具返回的 JSON 结构 */ /** task 工具返回的 JSON 结构 */