feat: 添加工具结果处理功能,支持执行时长记录和显示
This commit is contained in:
parent
eebfe0faa5
commit
f8fc0f7d0f
@ -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:
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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') {
|
||||||
|
|||||||
@ -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 ? (
|
||||||
|
|||||||
@ -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 ? (
|
||||||
|
|||||||
@ -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': {
|
||||||
|
|||||||
@ -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 结构 */
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user