// Copyright 2026 The Casdoor Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package object import ( "encoding/json" "fmt" "sort" "strings" "github.com/casdoor/casdoor/util" ) type OpenClawSessionGraph struct { Nodes []*OpenClawSessionGraphNode `json:"nodes"` Edges []*OpenClawSessionGraphEdge `json:"edges"` Stats OpenClawSessionGraphStats `json:"stats"` RawTranscript *OpenClawRawTranscriptRef `json:"rawTranscript,omitempty"` } type OpenClawSessionGraphNode struct { ID string `json:"id"` ParentID string `json:"parentId,omitempty"` OriginalParentID string `json:"originalParentId,omitempty"` EntryID string `json:"entryId,omitempty"` ToolCallID string `json:"toolCallId,omitempty"` Kind string `json:"kind"` Timestamp string `json:"timestamp"` Summary string `json:"summary"` Tool string `json:"tool,omitempty"` Query string `json:"query,omitempty"` URL string `json:"url,omitempty"` Path string `json:"path,omitempty"` OK *bool `json:"ok,omitempty"` Error string `json:"error,omitempty"` Text string `json:"text,omitempty"` IsAnchor bool `json:"isAnchor"` } type OpenClawSessionGraphEdge struct { Source string `json:"source"` Target string `json:"target"` } type OpenClawSessionGraphStats struct { TotalNodes int `json:"totalNodes"` TaskCount int `json:"taskCount"` ToolCallCount int `json:"toolCallCount"` ToolResultCount int `json:"toolResultCount"` FinalCount int `json:"finalCount"` FailedCount int `json:"failedCount"` } type openClawSessionGraphBuilder struct { graph *OpenClawSessionGraph nodes map[string]*OpenClawSessionGraphNode edges []*OpenClawSessionGraphEdge edgeKeys map[string]struct{} } type openClawSessionGraphRecord struct { Entry *Entry Payload openClawBehaviorPayload } type openClawAssistantStepGroup struct { ParentID string Timestamp string ToolNames []string Text string ToolCallNodeIDs []string ToolResultNodeIDs []string } func GetOpenClawSessionGraph(id string) (*OpenClawSessionGraph, error) { entry, _, anchorPayload, err := loadOpenClawSessionEntry(id) if entry == nil { return nil, err } if err != nil { return nil, err } records, err := collectOpenClawSessionGraphRecords(entry, anchorPayload) if err != nil { return nil, fmt.Errorf("failed to load OpenClaw session entries from database: %w", err) } graph := buildOpenClawSessionGraphFromEntries(anchorPayload, entry.Name, records) rawTranscript, err := getOpenClawRawTranscriptRef(entry.Owner, entry.Provider, anchorPayload.SessionID) if err != nil { fmt.Printf("GetOpenClawSessionGraph: failed to load raw transcript ref for provider %s session %s: %v\n", entry.Provider, anchorPayload.SessionID, err) return graph, nil } graph.RawTranscript = rawTranscript return graph, nil } func parseOpenClawSessionGraphPayload(entry *Entry) (openClawBehaviorPayload, error) { if entry == nil { return openClawBehaviorPayload{}, fmt.Errorf("entry is nil") } message := strings.TrimSpace(entry.Message) if message == "" { return openClawBehaviorPayload{}, fmt.Errorf("message is empty") } var payload openClawBehaviorPayload if err := json.Unmarshal([]byte(message), &payload); err != nil { return openClawBehaviorPayload{}, err } payload.SessionID = strings.TrimSpace(payload.SessionID) payload.EntryID = strings.TrimSpace(payload.EntryID) payload.ToolCallID = strings.TrimSpace(payload.ToolCallID) payload.ParentID = strings.TrimSpace(payload.ParentID) payload.Kind = strings.TrimSpace(payload.Kind) payload.Summary = strings.TrimSpace(payload.Summary) payload.Tool = strings.TrimSpace(payload.Tool) payload.Query = strings.TrimSpace(payload.Query) payload.URL = strings.TrimSpace(payload.URL) payload.Path = strings.TrimSpace(payload.Path) payload.Error = strings.TrimSpace(payload.Error) payload.AssistantText = strings.TrimSpace(payload.AssistantText) payload.Text = strings.TrimSpace(payload.Text) payload.Timestamp = strings.TrimSpace(firstNonEmpty(payload.Timestamp, entry.CreatedTime)) if payload.SessionID == "" { return openClawBehaviorPayload{}, fmt.Errorf("sessionId is empty") } if payload.EntryID == "" { return openClawBehaviorPayload{}, fmt.Errorf("entryId is empty") } if payload.Kind == "" { return openClawBehaviorPayload{}, fmt.Errorf("kind is empty") } return payload, nil } func collectOpenClawSessionGraphRecords(anchorEntry *Entry, anchorPayload openClawBehaviorPayload) ([]openClawSessionGraphRecord, error) { if anchorEntry == nil { return nil, fmt.Errorf("anchor entry is nil") } entries := []*Entry{} query := ormer.Engine.Where("owner = ? and type = ?", anchorEntry.Owner, "session") if providerName := strings.TrimSpace(anchorEntry.Provider); providerName != "" { query = query.And("provider = ?", providerName) } if err := query. Asc("created_time"). Asc("name"). Find(&entries); err != nil { return nil, err } return filterOpenClawSessionGraphRecords(anchorEntry, anchorPayload, entries), nil } func filterOpenClawSessionGraphRecords(anchorEntry *Entry, anchorPayload openClawBehaviorPayload, entries []*Entry) []openClawSessionGraphRecord { targetSessionID := strings.TrimSpace(anchorPayload.SessionID) records := make([]openClawSessionGraphRecord, 0, len(entries)+1) hasAnchor := false for _, candidate := range entries { if candidate == nil { continue } payload, err := parseOpenClawSessionGraphPayload(candidate) if err != nil { continue } if payload.SessionID != targetSessionID { continue } records = append(records, openClawSessionGraphRecord{ Entry: candidate, Payload: payload, }) if candidate.Owner == anchorEntry.Owner && candidate.Name == anchorEntry.Name { hasAnchor = true } } if !hasAnchor && anchorEntry != nil { records = append(records, openClawSessionGraphRecord{ Entry: anchorEntry, Payload: anchorPayload, }) } sort.SliceStable(records, func(i, j int) bool { leftPayload := records[i].Payload rightPayload := records[j].Payload leftTimestamp := strings.TrimSpace(firstNonEmpty(leftPayload.Timestamp, records[i].Entry.CreatedTime)) rightTimestamp := strings.TrimSpace(firstNonEmpty(rightPayload.Timestamp, records[j].Entry.CreatedTime)) if timestampOrder := compareOpenClawGraphTimestamps(leftTimestamp, rightTimestamp); timestampOrder != 0 { return timestampOrder < 0 } return records[i].Entry.Name < records[j].Entry.Name }) return records } func buildOpenClawSessionGraphFromEntries(anchorPayload openClawBehaviorPayload, anchorEntryName string, records []openClawSessionGraphRecord) *OpenClawSessionGraph { builder := newOpenClawSessionGraphBuilder() nodeIDsByEntryName := map[string][]string{} assistantGroups := map[string]*openClawAssistantStepGroup{} toolCallNodesByAssistant := map[string][]*OpenClawSessionGraphNode{} toolCallNodeIDByToolCallID := map[string]string{} toolCallNodeIDToAssistantID := map[string]string{} allToolCallNodes := []*OpenClawSessionGraphNode{} toolResultRecords := []openClawSessionGraphRecord{} for _, record := range records { payload := record.Payload switch payload.Kind { case "task": builder.addNode(&OpenClawSessionGraphNode{ ID: payload.EntryID, ParentID: payload.ParentID, EntryID: payload.EntryID, Kind: "task", Timestamp: payload.Timestamp, Summary: payload.Summary, Text: payload.Text, }) appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, payload.EntryID) case "tool_call": nodeID := buildStoredToolCallNodeID(record.Entry, payload) builder.addNode(&OpenClawSessionGraphNode{ ID: nodeID, ParentID: payload.EntryID, EntryID: payload.EntryID, ToolCallID: payload.ToolCallID, Kind: "tool_call", Timestamp: payload.Timestamp, Summary: payload.Summary, Tool: payload.Tool, Query: payload.Query, URL: payload.URL, Path: payload.Path, Text: payload.Text, }) storedNode := builder.nodes[nodeID] appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, nodeID) if storedNode != nil { toolCallNodesByAssistant[payload.EntryID] = append(toolCallNodesByAssistant[payload.EntryID], storedNode) allToolCallNodes = append(allToolCallNodes, storedNode) } if payload.ToolCallID != "" && toolCallNodeIDByToolCallID[payload.ToolCallID] == "" { toolCallNodeIDByToolCallID[payload.ToolCallID] = nodeID } if toolCallNodeIDToAssistantID[nodeID] == "" { toolCallNodeIDToAssistantID[nodeID] = payload.EntryID } group := assistantGroups[payload.EntryID] if group == nil { group = &openClawAssistantStepGroup{ ParentID: payload.ParentID, Timestamp: payload.Timestamp, } assistantGroups[payload.EntryID] = group } group.ParentID = firstNonEmpty(group.ParentID, payload.ParentID) group.Timestamp = chooseEarlierTimestamp(group.Timestamp, payload.Timestamp) group.ToolNames = append(group.ToolNames, payload.Tool) group.Text = firstNonEmpty(group.Text, payload.AssistantText) group.ToolCallNodeIDs = appendUniqueString(group.ToolCallNodeIDs, nodeID) case "tool_result": toolResultRecords = append(toolResultRecords, record) case "final": builder.addNode(&OpenClawSessionGraphNode{ ID: payload.EntryID, ParentID: payload.ParentID, EntryID: payload.EntryID, Kind: "final", Timestamp: payload.Timestamp, Summary: payload.Summary, Text: payload.Text, }) appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, payload.EntryID) } } assistantIDs := make([]string, 0, len(assistantGroups)) for entryID := range assistantGroups { assistantIDs = append(assistantIDs, entryID) } sort.Strings(assistantIDs) for _, assistantID := range assistantIDs { group := assistantGroups[assistantID] builder.addNode(&OpenClawSessionGraphNode{ ID: assistantID, ParentID: strings.TrimSpace(group.ParentID), EntryID: assistantID, Kind: "assistant_step", Timestamp: strings.TrimSpace(group.Timestamp), Summary: buildAssistantStepSummary(group.ToolNames), Text: strings.TrimSpace(group.Text), }) } for _, record := range toolResultRecords { payload := record.Payload parentID := strings.TrimSpace(payload.ParentID) originalParentID := "" if payload.ToolCallID != "" { if matchedNodeID := strings.TrimSpace(toolCallNodeIDByToolCallID[payload.ToolCallID]); matchedNodeID != "" { originalParentID = parentID parentID = matchedNodeID } } if parentID == strings.TrimSpace(payload.ParentID) { if matchedNodeID := matchToolResultToolCallNodeID(payload, toolCallNodesByAssistant[payload.ParentID], allToolCallNodes); matchedNodeID != "" && matchedNodeID != parentID { originalParentID = parentID parentID = matchedNodeID } } builder.addNode(&OpenClawSessionGraphNode{ ID: payload.EntryID, ParentID: parentID, OriginalParentID: originalParentID, EntryID: payload.EntryID, ToolCallID: payload.ToolCallID, Kind: "tool_result", Timestamp: payload.Timestamp, Summary: payload.Summary, Tool: payload.Tool, Query: payload.Query, URL: payload.URL, Path: payload.Path, OK: cloneBoolPointer(payload.OK), Error: payload.Error, Text: payload.Text, }) appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, payload.EntryID) if assistantID := findAssistantStepIDForToolResult(parentID, toolCallNodeIDToAssistantID); assistantID != "" { if group := assistantGroups[assistantID]; group != nil { group.ToolResultNodeIDs = appendUniqueString(group.ToolResultNodeIDs, payload.EntryID) } } } addOpenClawControlFlowEdges(builder, assistantIDs, assistantGroups) markStoredGraphAnchor(builder, anchorPayload, anchorEntryName, nodeIDsByEntryName) return builder.finalize() } func addOpenClawControlFlowEdges(builder *openClawSessionGraphBuilder, assistantIDs []string, assistantGroups map[string]*openClawAssistantStepGroup) { if builder == nil { return } for _, node := range builder.nodes { if node == nil || node.Kind != "task" { continue } builder.addEdge(node.ParentID, node.ID) } for _, assistantID := range assistantIDs { group := assistantGroups[assistantID] if group == nil { continue } for _, toolCallNodeID := range group.ToolCallNodeIDs { builder.addEdge(assistantID, toolCallNodeID) } } for _, node := range builder.nodes { if node == nil || node.Kind != "tool_result" { continue } builder.addEdge(node.ParentID, node.ID) } downstreamRawParents := map[string][]string{} for _, node := range builder.nodes { if node == nil { continue } if node.Kind != "assistant_step" && node.Kind != "final" { continue } if parentID := strings.TrimSpace(node.ParentID); parentID != "" { downstreamRawParents[parentID] = appendUniqueString(downstreamRawParents[parentID], node.ID) } } joinedDownstreamNodeIDs := map[string]struct{}{} for _, assistantID := range assistantIDs { group := assistantGroups[assistantID] if group == nil { continue } toolCallNodeIDs := uniqueStrings(group.ToolCallNodeIDs) toolResultNodeIDs := uniqueStrings(group.ToolResultNodeIDs) if len(toolCallNodeIDs) <= 1 || len(toolResultNodeIDs) <= 1 { continue } downstreamNodeIDs := []string{} for _, toolResultNodeID := range toolResultNodeIDs { downstreamNodeIDs = appendUniqueStrings(downstreamNodeIDs, downstreamRawParents[toolResultNodeID]) } if len(downstreamNodeIDs) == 0 { continue } joinID := fmt.Sprintf("join:%s", assistantID) joinTimestamp := "" for _, toolResultNodeID := range toolResultNodeIDs { if node := builder.nodes[toolResultNodeID]; node != nil { joinTimestamp = chooseLaterTimestamp(joinTimestamp, node.Timestamp) } } builder.addNode(&OpenClawSessionGraphNode{ ID: joinID, Kind: "join", Timestamp: joinTimestamp, Summary: "join", }) for _, toolResultNodeID := range toolResultNodeIDs { builder.addEdge(toolResultNodeID, joinID) } for _, downstreamNodeID := range downstreamNodeIDs { builder.addEdge(joinID, downstreamNodeID) if downstreamNode := builder.nodes[downstreamNodeID]; downstreamNode != nil { downstreamNode.ParentID = joinID } joinedDownstreamNodeIDs[downstreamNodeID] = struct{}{} } } for _, node := range builder.nodes { if node == nil { continue } switch node.Kind { case "assistant_step", "final": if _, ok := joinedDownstreamNodeIDs[node.ID]; ok { continue } builder.addEdge(node.ParentID, node.ID) } } } func appendGraphNodeEntryName(index map[string][]string, entry *Entry, nodeID string) { if index == nil || entry == nil { return } entryName := strings.TrimSpace(entry.Name) nodeID = strings.TrimSpace(nodeID) if entryName == "" || nodeID == "" { return } for _, existingNodeID := range index[entryName] { if existingNodeID == nodeID { return } } index[entryName] = append(index[entryName], nodeID) } func matchToolResultToolCallNodeID(payload openClawBehaviorPayload, assistantToolCalls []*OpenClawSessionGraphNode, allToolCalls []*OpenClawSessionGraphNode) string { if matchedNodeID := chooseMatchingToolCallNodeID(payload, assistantToolCalls); matchedNodeID != "" { return matchedNodeID } if len(assistantToolCalls) != len(allToolCalls) { return chooseMatchingToolCallNodeID(payload, allToolCalls) } return "" } func chooseMatchingToolCallNodeID(payload openClawBehaviorPayload, candidates []*OpenClawSessionGraphNode) string { filtered := make([]*OpenClawSessionGraphNode, 0, len(candidates)) seenNodeIDs := map[string]struct{}{} for _, candidate := range candidates { if candidate == nil || candidate.Kind != "tool_call" { continue } if _, ok := seenNodeIDs[candidate.ID]; ok { continue } seenNodeIDs[candidate.ID] = struct{}{} filtered = append(filtered, candidate) } if len(filtered) == 0 { return "" } if len(filtered) == 1 { return filtered[0].ID } filtered = refineToolCallCandidates(filtered, payload.Query, func(node *OpenClawSessionGraphNode) string { return node.Query }) if len(filtered) == 1 { return filtered[0].ID } filtered = refineToolCallCandidates(filtered, payload.URL, func(node *OpenClawSessionGraphNode) string { return node.URL }) if len(filtered) == 1 { return filtered[0].ID } filtered = refineToolCallCandidates(filtered, payload.Path, func(node *OpenClawSessionGraphNode) string { return node.Path }) if len(filtered) == 1 { return filtered[0].ID } filtered = refineToolCallCandidates(filtered, payload.Tool, func(node *OpenClawSessionGraphNode) string { return node.Tool }) if len(filtered) == 1 { return filtered[0].ID } return "" } func refineToolCallCandidates(candidates []*OpenClawSessionGraphNode, expected string, selector func(node *OpenClawSessionGraphNode) string) []*OpenClawSessionGraphNode { expected = strings.TrimSpace(expected) if expected == "" { return candidates } filtered := make([]*OpenClawSessionGraphNode, 0, len(candidates)) for _, candidate := range candidates { if strings.TrimSpace(selector(candidate)) == expected { filtered = append(filtered, candidate) } } if len(filtered) == 0 { return candidates } return filtered } func markStoredGraphAnchor(builder *openClawSessionGraphBuilder, anchorPayload openClawBehaviorPayload, anchorEntryName string, nodeIDsByEntryName map[string][]string) { anchorNodeID := "" if nodeIDs := nodeIDsByEntryName[strings.TrimSpace(anchorEntryName)]; len(nodeIDs) == 1 { anchorNodeID = nodeIDs[0] } if anchorNodeID == "" { switch anchorPayload.Kind { case "tool_call": candidates := []string{} for _, node := range builder.nodes { if !toolCallPayloadMatchesNode(anchorPayload, node) { continue } candidates = append(candidates, node.ID) } switch len(candidates) { case 1: anchorNodeID = candidates[0] default: anchorNodeID = anchorPayload.EntryID } default: if node := builder.nodes[anchorPayload.EntryID]; node != nil && node.Kind == anchorPayload.Kind { anchorNodeID = node.ID } } } if anchorNode := builder.nodes[anchorNodeID]; anchorNode != nil { anchorNode.IsAnchor = true } } func buildStoredToolCallNodeID(entry *Entry, payload openClawBehaviorPayload) string { if payload.ToolCallID != "" { return fmt.Sprintf("tool_call:%s", payload.ToolCallID) } if entry != nil && strings.TrimSpace(entry.Name) != "" { return fmt.Sprintf("tool_call_row:%s", strings.TrimSpace(entry.Name)) } return fmt.Sprintf("tool_call:%s", strings.TrimSpace(payload.EntryID)) } func newOpenClawSessionGraphBuilder() *openClawSessionGraphBuilder { return &openClawSessionGraphBuilder{ graph: &OpenClawSessionGraph{ Nodes: []*OpenClawSessionGraphNode{}, Edges: []*OpenClawSessionGraphEdge{}, }, nodes: map[string]*OpenClawSessionGraphNode{}, edges: []*OpenClawSessionGraphEdge{}, edgeKeys: map[string]struct{}{}, } } func (b *openClawSessionGraphBuilder) addNode(node *OpenClawSessionGraphNode) { if b == nil || node == nil { return } node.ID = strings.TrimSpace(node.ID) if node.ID == "" { return } if existing := b.nodes[node.ID]; existing != nil { mergeOpenClawGraphNode(existing, node) return } cloned := *node cloned.ParentID = strings.TrimSpace(cloned.ParentID) cloned.OriginalParentID = strings.TrimSpace(cloned.OriginalParentID) cloned.EntryID = strings.TrimSpace(cloned.EntryID) cloned.ToolCallID = strings.TrimSpace(cloned.ToolCallID) cloned.Kind = strings.TrimSpace(cloned.Kind) cloned.Timestamp = strings.TrimSpace(cloned.Timestamp) cloned.Summary = strings.TrimSpace(cloned.Summary) cloned.Tool = strings.TrimSpace(cloned.Tool) cloned.Query = strings.TrimSpace(cloned.Query) cloned.URL = strings.TrimSpace(cloned.URL) cloned.Path = strings.TrimSpace(cloned.Path) cloned.Error = strings.TrimSpace(cloned.Error) cloned.Text = strings.TrimSpace(cloned.Text) cloned.OK = cloneBoolPointer(cloned.OK) b.nodes[cloned.ID] = &cloned } func (b *openClawSessionGraphBuilder) addEdge(source, target string) { if b == nil { return } source = strings.TrimSpace(source) target = strings.TrimSpace(target) if source == "" || target == "" || source == target { return } key := fmt.Sprintf("%s->%s", source, target) if _, ok := b.edgeKeys[key]; ok { return } b.edgeKeys[key] = struct{}{} b.edges = append(b.edges, &OpenClawSessionGraphEdge{ Source: source, Target: target, }) } func (b *openClawSessionGraphBuilder) finalize() *OpenClawSessionGraph { if b == nil || b.graph == nil { return nil } nodeIDs := make([]string, 0, len(b.nodes)) for id := range b.nodes { nodeIDs = append(nodeIDs, id) } sort.Slice(nodeIDs, func(i, j int) bool { left := b.nodes[nodeIDs[i]] right := b.nodes[nodeIDs[j]] return compareGraphNodes(left, right) < 0 }) b.graph.Nodes = make([]*OpenClawSessionGraphNode, 0, len(nodeIDs)) b.graph.Stats = OpenClawSessionGraphStats{} for _, id := range nodeIDs { node := b.nodes[id] b.graph.Nodes = append(b.graph.Nodes, node) updateOpenClawSessionGraphStats(&b.graph.Stats, node) } b.graph.Edges = make([]*OpenClawSessionGraphEdge, 0, len(b.edges)) for _, edge := range b.edges { if edge == nil { continue } if b.nodes[edge.Source] == nil || b.nodes[edge.Target] == nil { continue } b.graph.Edges = append(b.graph.Edges, edge) } sort.Slice(b.graph.Edges, func(i, j int) bool { left := b.graph.Edges[i] right := b.graph.Edges[j] if left.Source != right.Source { return left.Source < right.Source } return left.Target < right.Target }) return b.graph } func mergeOpenClawGraphNode(current, next *OpenClawSessionGraphNode) { if current == nil || next == nil { return } current.ParentID = firstNonEmpty(current.ParentID, next.ParentID) current.OriginalParentID = firstNonEmpty(current.OriginalParentID, next.OriginalParentID) current.EntryID = firstNonEmpty(current.EntryID, next.EntryID) current.ToolCallID = firstNonEmpty(current.ToolCallID, next.ToolCallID) current.Kind = firstNonEmpty(current.Kind, next.Kind) current.Timestamp = chooseEarlierTimestamp(current.Timestamp, next.Timestamp) current.Summary = firstNonEmpty(current.Summary, next.Summary) current.Tool = firstNonEmpty(current.Tool, next.Tool) current.Query = firstNonEmpty(current.Query, next.Query) current.URL = firstNonEmpty(current.URL, next.URL) current.Path = firstNonEmpty(current.Path, next.Path) current.Error = firstNonEmpty(current.Error, next.Error) current.Text = firstNonEmpty(current.Text, next.Text) current.OK = mergeBoolPointers(current.OK, next.OK) current.IsAnchor = current.IsAnchor || next.IsAnchor } func updateOpenClawSessionGraphStats(stats *OpenClawSessionGraphStats, node *OpenClawSessionGraphNode) { if stats == nil || node == nil { return } stats.TotalNodes++ switch node.Kind { case "task": stats.TaskCount++ case "tool_call": stats.ToolCallCount++ case "tool_result": stats.ToolResultCount++ if node.OK != nil && !*node.OK { stats.FailedCount++ } case "final": stats.FinalCount++ } } func findAssistantStepIDForToolResult(toolCallNodeID string, toolCallNodeIDToAssistantID map[string]string) string { toolCallNodeID = strings.TrimSpace(toolCallNodeID) if toolCallNodeID == "" { return "" } return toolCallNodeIDToAssistantID[toolCallNodeID] } func appendUniqueString(values []string, value string) []string { value = strings.TrimSpace(value) if value == "" { return values } for _, existing := range values { if strings.TrimSpace(existing) == value { return values } } return append(values, value) } func appendUniqueStrings(values []string, additions []string) []string { for _, addition := range additions { values = appendUniqueString(values, addition) } return values } func uniqueStrings(values []string) []string { result := make([]string, 0, len(values)) for _, value := range values { result = appendUniqueString(result, value) } return result } func buildAssistantStepSummary(toolNames []string) string { deduped := []string{} seen := map[string]struct{}{} for _, toolName := range toolNames { toolName = strings.TrimSpace(toolName) if toolName == "" { continue } if _, ok := seen[toolName]; ok { continue } seen[toolName] = struct{}{} deduped = append(deduped, toolName) } if len(toolNames) == 0 { return "assistant step" } if len(deduped) == 0 { return fmt.Sprintf("%d tool calls", len(toolNames)) } if len(deduped) <= 3 { return fmt.Sprintf("%d tool calls: %s", len(toolNames), strings.Join(deduped, ", ")) } return fmt.Sprintf("%d tool calls: %s, ...", len(toolNames), strings.Join(deduped[:3], ", ")) } func toolCallPayloadMatchesNode(payload openClawBehaviorPayload, node *OpenClawSessionGraphNode) bool { if node == nil || node.Kind != "tool_call" { return false } if payload.ToolCallID != "" { return strings.TrimSpace(node.ToolCallID) == strings.TrimSpace(payload.ToolCallID) } if strings.TrimSpace(node.EntryID) != strings.TrimSpace(payload.EntryID) { return false } fields := []struct { payload string node string }{ {payload.Tool, node.Tool}, {payload.Query, node.Query}, {payload.URL, node.URL}, {payload.Path, node.Path}, {payload.Text, node.Text}, } matchedField := false for _, field := range fields { left := strings.TrimSpace(field.payload) if left == "" { continue } matchedField = true if left != strings.TrimSpace(field.node) { return false } } return matchedField } func compareGraphNodes(left, right *OpenClawSessionGraphNode) int { leftTimestamp := "" rightTimestamp := "" leftID := "" rightID := "" if left != nil { leftTimestamp = left.Timestamp leftID = left.ID } if right != nil { rightTimestamp = right.Timestamp rightID = right.ID } if timestampOrder := compareOpenClawGraphTimestamps(leftTimestamp, rightTimestamp); timestampOrder != 0 { return timestampOrder } if leftID < rightID { return -1 } if leftID > rightID { return 1 } return 0 } func chooseEarlierTimestamp(current, next string) string { current = strings.TrimSpace(current) next = strings.TrimSpace(next) if current == "" { return next } if next == "" { return current } if compareOpenClawGraphTimestamps(next, current) < 0 { return next } return current } func chooseLaterTimestamp(current, next string) string { current = strings.TrimSpace(current) next = strings.TrimSpace(next) if current == "" { return next } if next == "" { return current } if compareOpenClawGraphTimestamps(next, current) > 0 { return next } return current } func compareOpenClawGraphTimestamps(left, right string) int { left = strings.TrimSpace(left) right = strings.TrimSpace(right) leftUnixNano, leftOK := parseOpenClawGraphTimestamp(left) rightUnixNano, rightOK := parseOpenClawGraphTimestamp(right) if leftOK && rightOK { if leftUnixNano < rightUnixNano { return -1 } if leftUnixNano > rightUnixNano { return 1 } return 0 } if left < right { return -1 } if left > right { return 1 } return 0 } func parseOpenClawGraphTimestamp(timestamp string) (_ int64, ok bool) { timestamp = strings.TrimSpace(timestamp) if timestamp == "" { return 0, false } defer func() { if recover() != nil { ok = false } }() return util.String2Time(timestamp).UnixNano(), true } func mergeBoolPointers(current, next *bool) *bool { if next == nil { return current } if current == nil { return cloneBoolPointer(next) } value := *current && *next return &value } func cloneBoolPointer(value *bool) *bool { if value == nil { return nil } cloned := *value return &cloned }