feat: 添加工具结果处理功能,支持执行时长记录和显示
This commit is contained in:
parent
eebfe0faa5
commit
f8fc0f7d0f
@ -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<u64>) {
|
||||
self.handle(message).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// 装饰器:在内部 emitter 广播前,先将消息持久化到 DB
|
||||
@ -688,6 +694,17 @@ impl<H: EmittedMessageHandler> EmittedMessageHandler for PersistingEmittedMessag
|
||||
}
|
||||
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 {
|
||||
@ -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<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.
|
||||
///
|
||||
/// Returns true if:
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -662,6 +662,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option<WsOutbou
|
||||
content: msg.content.clone(),
|
||||
role: msg.role.clone(),
|
||||
subagent_task_id: None,
|
||||
duration_ms: None,
|
||||
}),
|
||||
ToolMessageState::PendingUserAction => Some(WsOutbound::ToolPending {
|
||||
id: msg.id.clone(),
|
||||
|
||||
@ -104,6 +104,8 @@ pub enum WsOutbound {
|
||||
role: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
subagent_task_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
duration_ms: Option<u64>,
|
||||
},
|
||||
#[serde(rename = "tool_pending")]
|
||||
ToolPending {
|
||||
|
||||
@ -57,6 +57,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
|
||||
content: message.content.clone(),
|
||||
role: message.role.clone(),
|
||||
subagent_task_id: None,
|
||||
duration_ms: None,
|
||||
}],
|
||||
ToolMessageState::PendingUserAction => 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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -192,6 +192,7 @@ function App() {
|
||||
...result[idx],
|
||||
status: 'result',
|
||||
resultContent: msg.content,
|
||||
durationMs: msg.durationMs,
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'tool_pending') {
|
||||
|
||||
@ -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}
|
||||
</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} />}
|
||||
<span className="ml-auto flex-shrink-0">
|
||||
{toolExpanded ? (
|
||||
|
||||
@ -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) {
|
||||
<span className={`text-xs flex-shrink-0 transition-colors duration-500 ${config.labelClass}`}>
|
||||
{config.label}
|
||||
</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>
|
||||
<span className="flex-shrink-0 ml-2">
|
||||
{isExpanded ? (
|
||||
|
||||
@ -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': {
|
||||
|
||||
@ -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 结构 */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user