diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx
index 0a047c89c3f..59e1e3bc008 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx
@@ -19,26 +19,26 @@ import {
Search as SearchIcon,
Tooltip,
} from '@/components/emcn'
-import { AgentSkillsIcon, WorkflowIcon } from '@/components/icons'
import { cn } from '@/lib/core/utils/cn'
import type { TraceSpan } from '@/lib/logs/types'
-import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
-import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
-import { getBlock, getBlockByToolName } from '@/blocks'
+import {
+ formatCostAmount,
+ formatTokensSummary,
+ formatTps,
+ formatTtft,
+ getBlockIconAndColor,
+ getDisplayName,
+ hasErrorInTree,
+ hasUnhandledErrorInTree,
+ isIterationType,
+ parseTime,
+} from '@/app/workspace/[workspaceId]/logs/components/log-details/utils'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
interface TraceSpansProps {
traceSpans?: TraceSpan[]
}
-/**
- * Checks if a span type is a loop or parallel iteration
- */
-function isIterationType(type: string): boolean {
- const lower = type?.toLowerCase() || ''
- return lower === 'loop-iteration' || lower === 'parallel-iteration'
-}
-
/**
* Creates a toggle handler for Set-based state
*/
@@ -59,124 +59,9 @@ function useSetToggle() {
)
}
-/**
- * Formats a token count with locale-aware thousands separators.
- * Returns `undefined` for missing or non-positive counts so callers can
- * filter them out before rendering.
- */
-function formatTokenCount(value: number | undefined): string | undefined {
- if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
- return value.toLocaleString('en-US')
-}
-
-/**
- * Builds a compact, dot-separated token summary for a span:
- * `"1,234 in · 567 out · 1,801 total"` with cache/reasoning appended when
- * present. Returns `undefined` when the span has no meaningful token data.
- */
-function formatTokensSummary(tokens: TraceSpan['tokens']): string | undefined {
- if (!tokens) return undefined
- const parts: string[] = []
- const input = formatTokenCount(tokens.input)
- const output = formatTokenCount(tokens.output)
- const total = formatTokenCount(tokens.total)
- const cacheRead = formatTokenCount(tokens.cacheRead)
- const cacheWrite = formatTokenCount(tokens.cacheWrite)
- const reasoning = formatTokenCount(tokens.reasoning)
- if (input) parts.push(`${input} in`)
- if (cacheRead) parts.push(`${cacheRead} cached`)
- if (cacheWrite) parts.push(`${cacheWrite} cache write`)
- if (output) parts.push(`${output} out`)
- if (reasoning) parts.push(`${reasoning} reasoning`)
- if (total) parts.push(`${total} total`)
- return parts.length > 0 ? parts.join(' · ') : undefined
-}
-
-/**
- * Formats a USD cost value for display. Shows `<$0.0001` for non-zero sub-cent
- * amounts so the user sees it was counted.
- */
-function formatCostAmount(value: number | undefined): string | undefined {
- if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
- if (value < 0.0001) return '<$0.0001'
- return `$${value.toFixed(4)}`
-}
-
-/**
- * Builds a compact cost summary: `"$0.0023 · $0.0001 in · $0.0022 out"`.
- * Falls back to whichever parts are present.
- */
function formatCostSummary(cost: TraceSpan['cost']): string | undefined {
if (!cost) return undefined
- const parts: string[] = []
- const total = formatCostAmount(cost.total)
- const input = formatCostAmount(cost.input)
- const output = formatCostAmount(cost.output)
- if (total) parts.push(total)
- if (input) parts.push(`${input} in`)
- if (output) parts.push(`${output} out`)
- return parts.length > 0 ? parts.join(' · ') : undefined
-}
-
-/**
- * Derives tokens-per-second from output tokens over segment duration.
- * Returns `undefined` when inputs are missing or non-positive.
- */
-function formatTps(outputTokens: number | undefined, durationMs: number): string | undefined {
- if (typeof outputTokens !== 'number' || !(outputTokens > 0)) return undefined
- if (!(durationMs > 0)) return undefined
- const tps = Math.round(outputTokens / (durationMs / 1000))
- if (!(tps > 0)) return undefined
- return `${tps.toLocaleString('en-US')} tok/s`
-}
-
-/**
- * Formats time-to-first-token. Uses `ms` below 1000, `s` above.
- */
-function formatTtft(ms: number | undefined): string | undefined {
- if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return undefined
- if (ms < 1000) return `${Math.round(ms)}ms`
- return `${(ms / 1000).toFixed(2)}s`
-}
-
-/**
- * Parses a time value to milliseconds
- */
-function parseTime(value?: string | number | null): number {
- if (!value) return 0
- const ms = typeof value === 'number' ? value : new Date(value).getTime()
- return Number.isFinite(ms) ? ms : 0
-}
-
-/**
- * Checks if a span or any of its descendants has an error (any error).
- */
-function hasErrorInTree(span: TraceSpan): boolean {
- if (span.status === 'error') return true
- if (span.children && span.children.length > 0) {
- return span.children.some((child) => hasErrorInTree(child))
- }
- if (span.toolCalls && span.toolCalls.length > 0) {
- return span.toolCalls.some((tc) => tc.error)
- }
- return false
-}
-
-/**
- * Checks if a span or any of its descendants has an unhandled error.
- * Spans with errorHandled: true (including containers that propagate it)
- * are skipped. Used only for the root workflow span to match the actual
- * workflow status.
- */
-function hasUnhandledErrorInTree(span: TraceSpan): boolean {
- if (span.status === 'error' && !span.errorHandled) return true
- if (span.children && span.children.length > 0) {
- return span.children.some((child) => hasUnhandledErrorInTree(child))
- }
- if (span.toolCalls && span.toolCalls.length > 0 && !span.errorHandled) {
- return span.toolCalls.some((tc) => tc.error)
- }
- return false
+ return formatCostAmount(cost.total)
}
/**
@@ -201,53 +86,6 @@ function normalizeAndSortSpans(spans: TraceSpan[]): TraceSpan[] {
})
}
-const DEFAULT_BLOCK_COLOR = '#6b7280'
-
-/**
- * Gets icon and color for a span type using block config
- */
-function getBlockIconAndColor(
- type: string,
- toolName?: string
-): {
- icon: React.ComponentType<{ className?: string }> | null
- bgColor: string
-} {
- const lowerType = type.toLowerCase()
-
- // Check for tool by name first (most specific)
- if (lowerType === 'tool' && toolName) {
- // Handle load_skill tool with the AgentSkillsIcon
- if (toolName === 'load_skill') {
- return { icon: AgentSkillsIcon, bgColor: '#8B5CF6' }
- }
- const toolBlock = getBlockByToolName(toolName)
- if (toolBlock) {
- return { icon: toolBlock.icon, bgColor: toolBlock.bgColor }
- }
- }
-
- // Special types not in block registry
- if (lowerType === 'loop' || lowerType === 'loop-iteration') {
- return { icon: LoopTool.icon, bgColor: LoopTool.bgColor }
- }
- if (lowerType === 'parallel' || lowerType === 'parallel-iteration') {
- return { icon: ParallelTool.icon, bgColor: ParallelTool.bgColor }
- }
- if (lowerType === 'workflow') {
- return { icon: WorkflowIcon, bgColor: '#6366F1' }
- }
-
- // Look up from block registry (model maps to agent)
- const blockType = lowerType === 'model' ? 'agent' : lowerType
- const blockConfig = getBlock(blockType)
- if (blockConfig) {
- return { icon: blockConfig.icon, bgColor: blockConfig.bgColor }
- }
-
- return { icon: null, bgColor: DEFAULT_BLOCK_COLOR }
-}
-
/**
* Renders the progress bar showing execution timeline
*/
@@ -403,10 +241,10 @@ function InputOutputSection({
{label}
{isExpanded && (
@@ -684,7 +522,7 @@ const TraceSpanNode = memo(function TraceSpanNode({
className='min-w-0 max-w-[180px] truncate font-medium text-caption'
style={{ color: showErrorStyle ? 'var(--text-error)' : 'var(--text-secondary)' }}
>
- {span.name}
+ {getDisplayName(span)}
{isToggleable && (
| null
- bgColor: string
-}
-
-/**
- * Parses a timestamp or numeric ms into milliseconds since epoch.
- */
-function parseTime(value?: string | number | null): number {
- if (!value) return 0
- const ms = typeof value === 'number' ? value : new Date(value).getTime()
- return Number.isFinite(ms) ? ms : 0
-}
-
-/**
- * Whether a span type represents a loop or parallel iteration container.
- */
-function isIterationType(type: string): boolean {
- const lower = type?.toLowerCase() || ''
- return lower === 'loop-iteration' || lower === 'parallel-iteration'
-}
-
/**
* Returns the stable id for a span, synthesized when absent.
*/
@@ -86,27 +70,6 @@ function getSpanId(span: TraceSpan): string {
return span.id || `span-${span.name}-${span.startTime}`
}
-/**
- * Walks a span's descendants to determine if any error exists in the subtree.
- */
-function hasErrorInTree(span: TraceSpan): boolean {
- if (span.status === 'error') return true
- if (span.children?.length) return span.children.some(hasErrorInTree)
- if (span.toolCalls?.length) return span.toolCalls.some((tc) => tc.error)
- return false
-}
-
-/**
- * Like `hasErrorInTree` but only counts errors that were not handled by an
- * error-handler path. Used for the root workflow status color.
- */
-function hasUnhandledErrorInTree(span: TraceSpan): boolean {
- if (span.status === 'error' && !span.errorHandled) return true
- if (span.children?.length) return span.children.some(hasUnhandledErrorInTree)
- if (span.toolCalls?.length && !span.errorHandled) return span.toolCalls.some((tc) => tc.error)
- return false
-}
-
/**
* Normalizes and sorts a tree of spans by start time.
*/
@@ -147,33 +110,6 @@ function getDisplayChildren(span: TraceSpan): TraceSpan[] {
return kids
}
-/**
- * Resolves the block icon and accent color for a trace span type.
- */
-function getBlockAppearance(type: string, toolName?: string, provider?: string): BlockAppearance {
- const lowerType = type.toLowerCase()
- if (lowerType === 'tool' && toolName) {
- if (toolName === 'load_skill') return { icon: AgentSkillsIcon, bgColor: '#8B5CF6' }
- const toolBlock = getBlockByToolName(toolName)
- if (toolBlock) return { icon: toolBlock.icon, bgColor: toolBlock.bgColor }
- }
- if (lowerType === 'loop' || lowerType === 'loop-iteration')
- return { icon: LoopTool.icon, bgColor: LoopTool.bgColor }
- if (lowerType === 'parallel' || lowerType === 'parallel-iteration')
- return { icon: ParallelTool.icon, bgColor: ParallelTool.bgColor }
- if (lowerType === 'workflow') return { icon: WorkflowIcon, bgColor: '#6366F1' }
- if (lowerType === 'model' && provider) {
- const providerDef = PROVIDER_DEFINITIONS[provider]
- if (providerDef?.icon) {
- return { icon: providerDef.icon, bgColor: providerDef.color ?? DEFAULT_BLOCK_COLOR }
- }
- }
- const blockType = lowerType === 'model' ? 'agent' : lowerType
- const blockConfig = getBlock(blockType)
- if (blockConfig) return { icon: blockConfig.icon, bgColor: blockConfig.bgColor }
- return { icon: null, bgColor: DEFAULT_BLOCK_COLOR }
-}
-
/** Returns 'text-white' for dark backgrounds, dark text for light ones. */
function iconColorClass(bgColor: string): string {
const hex = bgColor.replace('#', '')
@@ -184,30 +120,6 @@ function iconColorClass(bgColor: string): string {
return r * 299 + g * 587 + b * 114 > 160_000 ? 'text-[#111111]' : 'text-white'
}
-function formatTokenCount(value: number | undefined): string | undefined {
- if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
- return value.toLocaleString('en-US')
-}
-
-function formatCostAmount(value: number | undefined): string | undefined {
- if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
- if (value < 0.0001) return '<$0.0001'
- return `$${value.toFixed(4)}`
-}
-
-function formatTtft(ms: number | undefined): string | undefined {
- if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return undefined
- if (ms < 1000) return `${Math.round(ms)}ms`
- return `${(ms / 1000).toFixed(2)}s`
-}
-
-function formatTps(outputTokens: number | undefined, durationMs: number): string | undefined {
- if (typeof outputTokens !== 'number' || !(outputTokens > 0)) return undefined
- if (!(durationMs > 0)) return undefined
- const tps = Math.round(outputTokens / (durationMs / 1000))
- return tps > 0 ? `${tps.toLocaleString('en-US')} tok/s` : undefined
-}
-
/**
* Flattens the visible (expanded) span tree into a linear list for keyboard
* navigation, carrying depth, the chain of parent ids for indent drawing, and
@@ -294,7 +206,7 @@ function findSpan(spans: TraceSpan[], id: string | null): TraceSpan | null {
*/
function spanMatchesQuery(span: TraceSpan, query: string): boolean {
if (!query) return true
- return (span.name ?? '').toLowerCase().includes(query.toLowerCase())
+ return getDisplayName(span).toLowerCase().includes(query.toLowerCase())
}
/**
@@ -356,7 +268,7 @@ const TraceTreeRow = memo(function TraceTreeRow({
const duration = span.duration || endMs - startMs
const isRootWorkflow = depth === 0 && span.type?.toLowerCase() === 'workflow'
const hasError = isRootWorkflow ? hasUnhandledErrorInTree(span) : hasErrorInTree(span)
- const { icon: BlockIcon, bgColor } = getBlockAppearance(span.type, span.name, span.provider)
+ const { icon: BlockIcon, bgColor } = getBlockIconAndColor(span.type, span.name, span.provider)
const nameMatches = !!matchQuery && spanMatchesQuery(span, matchQuery)
const offsetMs = runStartMs > 0 ? Math.max(0, startMs - runStartMs) : 0
@@ -431,12 +343,12 @@ const TraceTreeRow = memo(function TraceTreeRow({
nameMatches && 'text-[var(--text-primary)]'
)}
>
- {span.name}
+ {getDisplayName(span)}
-
{span.name}
+
{getDisplayName(span)}
{formatDuration(duration, { precision: 2 }) || '—'}
{offsetMs > 0 && ` · +${formatDuration(offsetMs, { precision: 2 })}`}
@@ -738,7 +650,7 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa
}
const duration = span.duration || parseTime(span.endTime) - parseTime(span.startTime)
- const { icon: BlockIcon, bgColor } = getBlockAppearance(span.type, span.name, span.provider)
+ const { icon: BlockIcon, bgColor } = getBlockIconAndColor(span.type, span.name, span.provider)
const isRootWorkflow = span.type?.toLowerCase() === 'workflow'
const hasError = isRootWorkflow ? hasUnhandledErrorInTree(span) : hasErrorInTree(span)
const isDirectError = span.status === 'error'
@@ -797,7 +709,7 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa
hasError ? 'text-[var(--text-error)]' : 'text-[var(--text-primary)]'
)}
>
- {span.name}
+ {getDisplayName(span)}
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts
new file mode 100644
index 00000000000..afdf52bdaab
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts
@@ -0,0 +1,120 @@
+import type React from 'react'
+import { AgentSkillsIcon, WorkflowIcon } from '@/components/icons'
+import { dollarsToCredits } from '@/lib/billing/credits/conversion'
+import type { TraceSpan } from '@/lib/logs/types'
+import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
+import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
+import { getBlock, getBlockByToolName } from '@/blocks'
+import { PROVIDER_DEFINITIONS } from '@/providers/models'
+import { normalizeToolId } from '@/tools/normalize'
+
+export const DEFAULT_BLOCK_COLOR = '#6b7280'
+
+export interface BlockIconAndColor {
+ icon: React.ComponentType<{ className?: string }> | null
+ bgColor: string
+}
+
+export function isIterationType(type: string): boolean {
+ const lower = type?.toLowerCase() || ''
+ return lower === 'loop-iteration' || lower === 'parallel-iteration'
+}
+
+export function hasErrorInTree(span: TraceSpan): boolean {
+ if (span.status === 'error') return true
+ if (span.children?.length) return span.children.some(hasErrorInTree)
+ if (span.toolCalls?.length) return span.toolCalls.some((tc) => tc.error)
+ return false
+}
+
+export function hasUnhandledErrorInTree(span: TraceSpan): boolean {
+ if (span.status === 'error' && !span.errorHandled) return true
+ if (span.children?.length) return span.children.some(hasUnhandledErrorInTree)
+ if (span.toolCalls?.length && !span.errorHandled) return span.toolCalls.some((tc) => tc.error)
+ return false
+}
+
+export function getBlockIconAndColor(
+ type: string,
+ toolName?: string,
+ provider?: string
+): BlockIconAndColor {
+ const lowerType = type.toLowerCase()
+ if (lowerType === 'tool' && toolName) {
+ const normalized = normalizeToolId(toolName)
+ if (normalized === 'load_skill') return { icon: AgentSkillsIcon, bgColor: '#8B5CF6' }
+ const toolBlock = getBlockByToolName(normalized)
+ if (toolBlock) return { icon: toolBlock.icon, bgColor: toolBlock.bgColor }
+ }
+ if (lowerType === 'loop' || lowerType === 'loop-iteration')
+ return { icon: LoopTool.icon, bgColor: LoopTool.bgColor }
+ if (lowerType === 'parallel' || lowerType === 'parallel-iteration')
+ return { icon: ParallelTool.icon, bgColor: ParallelTool.bgColor }
+ if (lowerType === 'workflow') return { icon: WorkflowIcon, bgColor: '#6366F1' }
+ if (lowerType === 'model' && provider) {
+ const providerDef = PROVIDER_DEFINITIONS[provider]
+ if (providerDef?.icon)
+ return { icon: providerDef.icon, bgColor: providerDef.color ?? DEFAULT_BLOCK_COLOR }
+ }
+ const blockType = lowerType === 'model' ? 'agent' : lowerType
+ const blockConfig = getBlock(blockType)
+ if (blockConfig) return { icon: blockConfig.icon, bgColor: blockConfig.bgColor }
+ return { icon: null, bgColor: DEFAULT_BLOCK_COLOR }
+}
+
+export function parseTime(value?: string | number | null): number {
+ if (!value) return 0
+ const ms = typeof value === 'number' ? value : new Date(value).getTime()
+ return Number.isFinite(ms) ? ms : 0
+}
+
+export function formatTokenCount(value: number | undefined): string | undefined {
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
+ return value.toLocaleString('en-US')
+}
+
+export function formatTtft(ms: number | undefined): string | undefined {
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return undefined
+ if (ms < 1000) return `${Math.round(ms)}ms`
+ return `${(ms / 1000).toFixed(2)}s`
+}
+
+export function formatTps(
+ outputTokens: number | undefined,
+ durationMs: number
+): string | undefined {
+ if (typeof outputTokens !== 'number' || !(outputTokens > 0)) return undefined
+ if (!(durationMs > 0)) return undefined
+ const tps = Math.round(outputTokens / (durationMs / 1000))
+ return tps > 0 ? `${tps.toLocaleString('en-US')} tok/s` : undefined
+}
+
+export function getDisplayName(span: TraceSpan): string {
+ if (span.type?.toLowerCase() === 'tool') return normalizeToolId(span.name)
+ return span.name
+}
+
+export function formatCostAmount(value: number | undefined): string | undefined {
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
+ const credits = dollarsToCredits(value)
+ if (credits <= 0) return '<1 credit'
+ return `${credits.toLocaleString('en-US')} ${credits === 1 ? 'credit' : 'credits'}`
+}
+
+export function formatTokensSummary(tokens: TraceSpan['tokens']): string | undefined {
+ if (!tokens) return undefined
+ const parts: string[] = []
+ const input = formatTokenCount(tokens.input)
+ const output = formatTokenCount(tokens.output)
+ const total = formatTokenCount(tokens.total)
+ const cacheRead = formatTokenCount(tokens.cacheRead)
+ const cacheWrite = formatTokenCount(tokens.cacheWrite)
+ const reasoning = formatTokenCount(tokens.reasoning)
+ if (input) parts.push(`${input} in`)
+ if (cacheRead) parts.push(`${cacheRead} cached`)
+ if (cacheWrite) parts.push(`${cacheWrite} cache write`)
+ if (output) parts.push(`${output} out`)
+ if (reasoning) parts.push(`${reasoning} reasoning`)
+ if (total) parts.push(`${total} total`)
+ return parts.length > 0 ? parts.join(' · ') : undefined
+}
diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts
index 3d173c6b4ca..0a665bc3e4e 100644
--- a/apps/sim/tools/index.ts
+++ b/apps/sim/tools/index.ts
@@ -550,44 +550,9 @@ async function applyHostedKeyCostToResult(
}
}
-/**
- * Normalizes a tool ID by stripping resource ID suffix (UUID/tableId).
- * Workflow tools: 'workflow_executor_' -> 'workflow_executor'
- * Knowledge tools: 'knowledge_search_' -> 'knowledge_search'
- * Table tools: 'table_query_rows_' -> 'table_query_rows'
- */
-function normalizeToolId(toolId: string): string {
- if (toolId.startsWith('workflow_executor_') && toolId.length > 'workflow_executor_'.length) {
- return 'workflow_executor'
- }
-
- const knowledgeOps = ['knowledge_search', 'knowledge_upload_chunk', 'knowledge_create_document']
- for (const op of knowledgeOps) {
- if (toolId.startsWith(`${op}_`) && toolId.length > op.length + 1) {
- return op
- }
- }
+import { normalizeToolId } from '@/tools/normalize'
- const tableOps = [
- 'table_query_rows',
- 'table_insert_row',
- 'table_batch_insert_rows',
- 'table_update_row',
- 'table_update_rows_by_filter',
- 'table_delete_rows_by_filter',
- 'table_upsert_row',
- 'table_get_row',
- 'table_delete_row',
- 'table_get_schema',
- ]
- for (const op of tableOps) {
- if (toolId.startsWith(`${op}_`) && toolId.length > op.length + 1) {
- return op
- }
- }
-
- return toolId
-}
+export { normalizeToolId } from '@/tools/normalize'
/**
* Maximum request body size in bytes before we warn/error about size limits.
diff --git a/apps/sim/tools/normalize.ts b/apps/sim/tools/normalize.ts
new file mode 100644
index 00000000000..9082e79a5c0
--- /dev/null
+++ b/apps/sim/tools/normalize.ts
@@ -0,0 +1,40 @@
+/**
+ * Normalizes a tool ID by stripping resource ID suffix (UUID/tableId).
+ * Workflow tools: 'workflow_executor_' -> 'workflow_executor'
+ * Knowledge tools: 'knowledge_search_' -> 'knowledge_search'
+ * Table tools: 'table_query_rows_' -> 'table_query_rows'
+ *
+ * Pure string utility — no server dependencies, safe to import in client components.
+ */
+export function normalizeToolId(toolId: string): string {
+ if (toolId.startsWith('workflow_executor_') && toolId.length > 'workflow_executor_'.length) {
+ return 'workflow_executor'
+ }
+
+ const knowledgeOps = ['knowledge_search', 'knowledge_upload_chunk', 'knowledge_create_document']
+ for (const op of knowledgeOps) {
+ if (toolId.startsWith(`${op}_`) && toolId.length > op.length + 1) {
+ return op
+ }
+ }
+
+ const tableOps = [
+ 'table_query_rows',
+ 'table_insert_row',
+ 'table_batch_insert_rows',
+ 'table_update_row',
+ 'table_update_rows_by_filter',
+ 'table_delete_rows_by_filter',
+ 'table_upsert_row',
+ 'table_get_row',
+ 'table_delete_row',
+ 'table_get_schema',
+ ]
+ for (const op of tableOps) {
+ if (toolId.startsWith(`${op}_`) && toolId.length > op.length + 1) {
+ return op
+ }
+ }
+
+ return toolId
+}