From 4722caab3724696a8884de454bc11d31c9342365 Mon Sep 17 00:00:00 2001 From: mlogclub Date: Fri, 10 Apr 2026 15:04:02 +0800 Subject: [PATCH] feat: implement tool search tracing and logging in agent run logs --- .../impl/callbacks/agent_trace_handler.go | 76 +++++++++++++++++++ .../impl/callbacks/runlog_callback.go | 10 +++ .../internal/impl/callbacks/trace_callback.go | 15 ++++ internal/ai/runtime/reply_service.go | 73 +++++++++++++++++- internal/builders/agent_run_log_builder.go | 1 + internal/models/models.go | 1 + internal/pkg/dto/response/skill_response.go | 1 + web/app/(console)/agent-run-logs/page.tsx | 12 +++ web/lib/api/admin.ts | 1 + 9 files changed, 188 insertions(+), 2 deletions(-) diff --git a/internal/ai/runtime/internal/impl/callbacks/agent_trace_handler.go b/internal/ai/runtime/internal/impl/callbacks/agent_trace_handler.go index 38cb25a..87b61f7 100644 --- a/internal/ai/runtime/internal/impl/callbacks/agent_trace_handler.go +++ b/internal/ai/runtime/internal/impl/callbacks/agent_trace_handler.go @@ -6,6 +6,8 @@ import ( "strings" "time" + "cs-agent/internal/pkg/toolx" + einotool "github.com/cloudwego/eino/components/tool" "github.com/cloudwego/eino/adk" @@ -56,10 +58,25 @@ func (h *RuntimeTraceHandler) WrapInvokableToolCall(_ context.Context, endpoint item.ErrorMessage = err.Error() } h.collector.AddToolItem(item) + if metadata, ok := h.resolveToolMetadata(item.ToolName); ok && strings.TrimSpace(metadata.ToolCode) == toolx.BuiltinToolSearchToolCode { + h.collector.AddToolSearchItem(buildToolSearchTraceItem(argumentsInJSON, result, err)) + } return result, err }, nil } +func (h *RuntimeTraceHandler) resolveToolMetadata(modelToolName string) (ToolMetadata, bool) { + if h == nil || h.toolMetadataBy == nil { + return ToolMetadata{}, false + } + modelToolName = strings.TrimSpace(modelToolName) + if modelToolName == "" { + return ToolMetadata{}, false + } + metadata, ok := h.toolMetadataBy[modelToolName] + return metadata, ok +} + func parseToolArguments(argumentsInJSON string) map[string]any { argumentsInJSON = strings.TrimSpace(argumentsInJSON) if argumentsInJSON == "" { @@ -83,3 +100,62 @@ func previewToolText(text string, limit int) string { } return string(runes[:limit]) + "..." } + +func buildToolSearchTraceItem(argumentsInJSON string, result string, runErr error) ToolSearchTraceItem { + item := ToolSearchTraceItem{ + Status: "ok", + } + args := parseToolArguments(argumentsInJSON) + item.Query = strings.TrimSpace(readToolSearchString(args, "query")) + item.TargetToolCode = strings.TrimSpace(readToolSearchString(args, "toolCode")) + if item.TargetToolCode != "" { + item.Action = "invoke" + item.TargetServerCode, item.TargetToolName = toolx.SplitMCPToolCode(item.TargetToolCode) + } else { + item.Action = "search" + } + if runErr != nil { + item.Status = "error" + item.ErrorMessage = runErr.Error() + return item + } + payload := make(map[string]any) + if err := json.Unmarshal([]byte(strings.TrimSpace(result)), &payload); err != nil { + return item + } + candidateItems, _ := payload["candidates"].([]any) + item.CandidateToolCodes = extractCandidateToolCodes(candidateItems) + return item +} + +func readToolSearchString(data map[string]any, key string) string { + if len(data) == 0 { + return "" + } + value, ok := data[key] + if !ok { + return "" + } + text, _ := value.(string) + return text +} + +func extractCandidateToolCodes(items []any) []string { + if len(items) == 0 { + return nil + } + ret := make([]string, 0, len(items)) + for _, item := range items { + payload, ok := item.(map[string]any) + if !ok { + continue + } + toolCode, _ := payload["toolCode"].(string) + toolCode = strings.TrimSpace(toolCode) + if toolCode == "" { + continue + } + ret = append(ret, toolCode) + } + return ret +} diff --git a/internal/ai/runtime/internal/impl/callbacks/runlog_callback.go b/internal/ai/runtime/internal/impl/callbacks/runlog_callback.go index e323b99..e67e71e 100644 --- a/internal/ai/runtime/internal/impl/callbacks/runlog_callback.go +++ b/internal/ai/runtime/internal/impl/callbacks/runlog_callback.go @@ -39,3 +39,13 @@ func (c *RuntimeTraceCollector) AddToolItem(item ToolTraceItem) { c.Data.Tools.Count++ c.Data.Tools.Items = append(c.Data.Tools.Items, item) } + +func (c *RuntimeTraceCollector) AddToolSearchItem(item ToolSearchTraceItem) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.Data.ToolSearch.Count++ + c.Data.ToolSearch.Items = append(c.Data.ToolSearch.Items, item) +} diff --git a/internal/ai/runtime/internal/impl/callbacks/trace_callback.go b/internal/ai/runtime/internal/impl/callbacks/trace_callback.go index 05b5cc7..71eea7a 100644 --- a/internal/ai/runtime/internal/impl/callbacks/trace_callback.go +++ b/internal/ai/runtime/internal/impl/callbacks/trace_callback.go @@ -11,6 +11,17 @@ type ToolTraceItem struct { ErrorMessage string `json:"errorMessage,omitempty"` } +type ToolSearchTraceItem struct { + Action string `json:"action,omitempty"` + Query string `json:"query,omitempty"` + TargetToolCode string `json:"targetToolCode,omitempty"` + TargetServerCode string `json:"targetServerCode,omitempty"` + TargetToolName string `json:"targetToolName,omitempty"` + CandidateToolCodes []string `json:"candidateToolCodes,omitempty"` + Status string `json:"status,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` +} + type RetrieverTraceItem struct { Query string `json:"query,omitempty"` KnowledgeBaseID int64 `json:"knowledgeBaseId,omitempty"` @@ -47,6 +58,10 @@ type RuntimeTraceData struct { Count int `json:"count,omitempty"` Items []ToolTraceItem `json:"items,omitempty"` } `json:"tools"` + ToolSearch struct { + Count int `json:"count,omitempty"` + Items []ToolSearchTraceItem `json:"items,omitempty"` + } `json:"toolSearch"` Output struct { ReplyText string `json:"replyText,omitempty"` FinishReason string `json:"finishReason,omitempty"` diff --git a/internal/ai/runtime/reply_service.go b/internal/ai/runtime/reply_service.go index 316e94b..82c41ca 100644 --- a/internal/ai/runtime/reply_service.go +++ b/internal/ai/runtime/reply_service.go @@ -11,6 +11,7 @@ import ( "cs-agent/internal/models" "cs-agent/internal/pkg/dto" "cs-agent/internal/pkg/enums" + "cs-agent/internal/pkg/toolx" "cs-agent/internal/repositories" svc "cs-agent/internal/services" @@ -331,6 +332,7 @@ func (s *aiReplyService) writeRunLog(startedAt time.Time, message models.Message PlannedSkillCode: strings.TrimSpace(summaryPlannedSkillCode(summary)), PlannedSkillName: strings.TrimSpace(summaryPlannedSkillName(summary)), SkillRouteTrace: strings.TrimSpace(summarySkillRouteTrace(summary)), + ToolSearchTrace: extractToolSearchTrace(summary), PlannedToolCode: plannedToolCode, PlanReason: planReason, InterruptType: firstInterruptType(summary), @@ -378,10 +380,15 @@ func buildRunLogPlan(summary *Summary) (plannedAction, plannedToolCode, planReas return "interrupt", "", "pending interrupt checkpoint expired" } if summary.Interrupted { - return "tool", firstInvokedToolCode(summary), "agent interrupted and is waiting for user confirmation" + return "tool", summaryPrimaryToolCode(summary), "agent interrupted and is waiting for user confirmation" } if len(summary.InvokedToolCodes) > 0 { - return "tool", strings.TrimSpace(summary.InvokedToolCodes[0]), "agent invoked MCP tool" + toolCode := summaryPrimaryToolCode(summary) + reason := "agent invoked MCP tool" + if toolCode != "" && toolCode != firstInvokedToolCode(summary) { + reason = "agent invoked dynamic tool via tool_search" + } + return "tool", toolCode, reason } if strings.TrimSpace(summary.ReplyText) != "" { return "reply", "", "agent replied directly" @@ -541,6 +548,68 @@ func firstInvokedToolCode(summary *Summary) string { return "" } +func summaryPrimaryToolCode(summary *Summary) string { + if summary == nil { + return "" + } + toolCode := firstInvokedToolCode(summary) + if toolCode != toolx.BuiltinToolSearchToolCode { + return toolCode + } + if targetToolCode := firstToolSearchTargetToolCode(summary); targetToolCode != "" { + return targetToolCode + } + return toolCode +} + +func extractToolSearchTrace(summary *Summary) string { + if summary == nil { + return "" + } + trace := parseRuntimeTraceData(summary.TraceData) + if len(trace.ToolSearch.Items) == 0 || len(trace.ToolSearch.Raw) == 0 { + return "" + } + return string(trace.ToolSearch.Raw) +} + +func firstToolSearchTargetToolCode(summary *Summary) string { + trace := parseRuntimeTraceData(summary.TraceData) + for _, item := range trace.ToolSearch.Items { + toolCode := strings.TrimSpace(item.TargetToolCode) + if toolCode != "" { + return toolCode + } + } + return "" +} + +type runtimeTraceProjection struct { + ToolSearch struct { + Raw json.RawMessage `json:"-"` + Items []struct { + TargetToolCode string `json:"targetToolCode"` + } `json:"items"` + } `json:"toolSearch"` +} + +func parseRuntimeTraceData(raw string) runtimeTraceProjection { + raw = strings.TrimSpace(raw) + if raw == "" { + return runtimeTraceProjection{} + } + var payload map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return runtimeTraceProjection{} + } + var trace runtimeTraceProjection + if toolSearchRaw, ok := payload["toolSearch"]; ok && len(toolSearchRaw) > 0 { + trace.ToolSearch.Raw = append(json.RawMessage(nil), toolSearchRaw...) + _ = json.Unmarshal(toolSearchRaw, &trace.ToolSearch) + } + return trace +} + func isCancellationReply(replyText string) bool { replyText = strings.TrimSpace(replyText) return strings.Contains(replyText, "已取消本次工单创建") diff --git a/internal/builders/agent_run_log_builder.go b/internal/builders/agent_run_log_builder.go index 94f40aa..6daa240 100644 --- a/internal/builders/agent_run_log_builder.go +++ b/internal/builders/agent_run_log_builder.go @@ -20,6 +20,7 @@ func BuildAgentRunLog(item *models.AgentRunLog) response.AgentRunLogResponse { PlannedSkillCode: item.PlannedSkillCode, PlannedSkillName: item.PlannedSkillName, SkillRouteTrace: item.SkillRouteTrace, + ToolSearchTrace: item.ToolSearchTrace, PlannedToolCode: item.PlannedToolCode, PlanReason: item.PlanReason, InterruptType: item.InterruptType, diff --git a/internal/models/models.go b/internal/models/models.go index 8491b9d..d59d053 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -952,6 +952,7 @@ type AgentRunLog struct { PlannedSkillCode string `gorm:"type:varchar(100);not null;default:'';index"` PlannedSkillName string `gorm:"type:varchar(100);not null;default:''"` SkillRouteTrace string `gorm:"type:text"` + ToolSearchTrace string `gorm:"type:text"` PlannedToolCode string `gorm:"type:varchar(200);not null;default:'';index"` PlanReason string `gorm:"type:varchar(500);not null;default:''"` InterruptType string `gorm:"type:varchar(50);not null;default:'';index"` diff --git a/internal/pkg/dto/response/skill_response.go b/internal/pkg/dto/response/skill_response.go index 1745ede..8c93f42 100644 --- a/internal/pkg/dto/response/skill_response.go +++ b/internal/pkg/dto/response/skill_response.go @@ -48,6 +48,7 @@ type AgentRunLogResponse struct { PlannedSkillCode string `json:"plannedSkillCode"` PlannedSkillName string `json:"plannedSkillName"` SkillRouteTrace string `json:"skillRouteTrace"` + ToolSearchTrace string `json:"toolSearchTrace"` PlannedToolCode string `json:"plannedToolCode"` PlanReason string `json:"planReason"` InterruptType string `json:"interruptType"` diff --git a/web/app/(console)/agent-run-logs/page.tsx b/web/app/(console)/agent-run-logs/page.tsx index 4725f15..c981428 100644 --- a/web/app/(console)/agent-run-logs/page.tsx +++ b/web/app/(console)/agent-run-logs/page.tsx @@ -91,6 +91,10 @@ export default function DashboardAgentRunLogsPage() { () => safeParseJSON(activeLog?.traceData ?? ""), [activeLog?.traceData] ) + const activeToolSearchTrace = useMemo( + () => safeParseJSON(activeLog?.toolSearchTrace ?? ""), + [activeLog?.toolSearchTrace] + ) const aiAgentOptions = useMemo( () => [ @@ -372,6 +376,14 @@ export default function DashboardAgentRunLogsPage() { ]} /> + } title="用户问题" diff --git a/web/lib/api/admin.ts b/web/lib/api/admin.ts index 5190b95..e76f8b4 100644 --- a/web/lib/api/admin.ts +++ b/web/lib/api/admin.ts @@ -365,6 +365,7 @@ export type AgentRunLog = { plannedSkillCode: string plannedSkillName: string skillRouteTrace: string + toolSearchTrace: string plannedToolCode: string planReason: string interruptType: string