fix(trace): normalize keyed tool names and show credits in trace view (#4344)

* fix(trace): normalize keyed tool names and show credits in trace view

- Export normalizeToolId from tools/index.ts so trace-view can reuse it
- Strip resource-id suffixes (knowledge_search_<uuid>, workflow_executor_<uuid>, table_*_<id>) from tool span names at display time so icons resolve and names are readable
- Replace raw dollar formatting with credits in trace header and agent detail panel

* fix(trace): fix sub-credit display, locale, and pluralization in formatCostAmount

* fix(trace): extract normalizeToolId to client-safe module, consolidate log-details formatting utils

* refactor(trace): consolidate shared span utilities into log-details/utils

* fix(tools): import normalizeToolId for internal use after extracting to normalize.ts

* style(trace-spans): replace inline transform style with Tailwind rotate-180 on disclosure chevron

* fix(trace-spans): show credits instead of dollars for cost display

* refactor(trace): move formatCostAmount and getDisplayName to shared utils, normalize tool names in trace-spans

* fix(trace-spans): simplify formatCostSummary to show total credits only
This commit is contained in:
Waleed
2026-04-29 15:34:02 -07:00
committed by GitHub
parent 1a321c5d5f
commit a9d4e2eca8
5 changed files with 198 additions and 323 deletions
@@ -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}
</span>
<ChevronDown
className='h-[8px] w-[8px] text-[var(--text-tertiary)] transition-colors transition-transform group-hover:text-[var(--text-primary)]'
style={{
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
}}
className={cn(
'h-[8px] w-[8px] text-[var(--text-tertiary)] transition-colors transition-transform duration-100 group-hover:text-[var(--text-primary)]',
isExpanded && 'rotate-180'
)}
/>
</div>
{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)}
</span>
{isToggleable && (
<ChevronDown
@@ -29,16 +29,22 @@ 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,
formatTokenCount,
formatTps,
formatTtft,
getBlockIconAndColor,
getDisplayName,
hasErrorInTree,
hasUnhandledErrorInTree,
isIterationType,
parseTime,
} from '@/app/workspace/[workspaceId]/logs/components/log-details/utils'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
const DEFAULT_BLOCK_COLOR = '#6b7280'
const DEFAULT_TREE_PANE_WIDTH = 360
const MIN_TREE_PANE_WIDTH = 200
const MAX_TREE_PANE_WIDTH = 600
@@ -57,28 +63,6 @@ interface FlatSpanEntry {
parentDuration?: number
}
interface BlockAppearance {
icon: React.ComponentType<{ className?: string }> | 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>
</Tooltip.Trigger>
<Tooltip.Content side='right' className='max-w-[320px]'>
<div className='flex flex-col gap-0.5'>
<span className='font-medium'>{span.name}</span>
<span className='font-medium'>{getDisplayName(span)}</span>
<span className='text-[var(--text-tertiary)] text-caption'>
{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)}
</h3>
<div className='flex items-center gap-1.5 font-medium text-[var(--text-tertiary)] text-caption'>
<Badge variant={hasError ? 'red' : 'green'} size='sm'>
@@ -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
}
+2 -37
View File
@@ -550,44 +550,9 @@ async function applyHostedKeyCostToResult(
}
}
/**
* Normalizes a tool ID by stripping resource ID suffix (UUID/tableId).
* Workflow tools: 'workflow_executor_<uuid>' -> 'workflow_executor'
* Knowledge tools: 'knowledge_search_<uuid>' -> 'knowledge_search'
* Table tools: 'table_query_rows_<tableId>' -> 'table_query_rows'
*/
function normalizeToolId(toolId: string): string {
if (toolId.startsWith('workflow_executor_') && toolId.length > 'workflow_executor_'.length) {
return 'workflow_executor'
}
import { normalizeToolId } from '@/tools/normalize'
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
}
export { normalizeToolId } from '@/tools/normalize'
/**
* Maximum request body size in bytes before we warn/error about size limits.
+40
View File
@@ -0,0 +1,40 @@
/**
* Normalizes a tool ID by stripping resource ID suffix (UUID/tableId).
* Workflow tools: 'workflow_executor_<uuid>' -> 'workflow_executor'
* Knowledge tools: 'knowledge_search_<uuid>' -> 'knowledge_search'
* Table tools: 'table_query_rows_<tableId>' -> '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
}