improvement(tool-input): general abstraction to enrich agent context, reuse visibility helpers (#2872)

* add abstraction for schema enrichment, improve agent KB block experience for tags, fix visibility of subblocks

* cleanup code

* consolidate

* fix workflow tool react query

* fix deployed context propagation

* fix tests
This commit is contained in:
Vikhyath Mondreti
2026-01-17 19:13:27 -08:00
committed by GitHub
parent b8b20576d3
commit 0fcd52683a
25 changed files with 967 additions and 342 deletions

View File

@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants'
import { createTagDefinition, getTagDefinitions } from '@/lib/knowledge/tags/service'
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'
@@ -19,19 +19,32 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
try {
logger.info(`[${requestId}] Getting tag definitions for knowledge base ${knowledgeBaseId}`)
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
if (!auth.success) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
if (!accessCheck.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
// Only allow session and internal JWT auth (not API key)
if (auth.authType === 'api_key') {
return NextResponse.json(
{ error: 'API key auth not supported for this endpoint' },
{ status: 401 }
)
}
// For session auth, verify KB access. Internal JWT is trusted.
if (auth.authType === 'session' && auth.userId) {
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
if (!accessCheck.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const tagDefinitions = await getTagDefinitions(knowledgeBaseId)
logger.info(`[${requestId}] Retrieved ${tagDefinitions.length} tag definitions`)
logger.info(
`[${requestId}] Retrieved ${tagDefinitions.length} tag definitions (${auth.authType})`
)
return NextResponse.json({
success: true,
@@ -51,14 +64,25 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
try {
logger.info(`[${requestId}] Creating tag definition for knowledge base ${knowledgeBaseId}`)
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
if (!auth.success) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
if (!accessCheck.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
// Only allow session and internal JWT auth (not API key)
if (auth.authType === 'api_key') {
return NextResponse.json(
{ error: 'API key auth not supported for this endpoint' },
{ status: 401 }
)
}
// For session auth, verify KB access. Internal JWT is trusted.
if (auth.authType === 'session' && auth.userId) {
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
if (!accessCheck.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const body = await req.json()

View File

@@ -15,6 +15,7 @@ interface DocumentSelectorProps {
onDocumentSelect?: (documentId: string) => void
isPreview?: boolean
previewValue?: string | null
previewContextValues?: Record<string, unknown>
}
export function DocumentSelector({
@@ -24,9 +25,15 @@ export function DocumentSelector({
onDocumentSelect,
isPreview = false,
previewValue,
previewContextValues,
}: DocumentSelectorProps) {
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
disabled,
isPreview,
previewContextValues,
})
const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId')
const knowledgeBaseIdValue = previewContextValues?.knowledgeBaseId ?? knowledgeBaseIdFromStore
const normalizedKnowledgeBaseId =
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
? knowledgeBaseIdValue

View File

@@ -37,6 +37,7 @@ interface DocumentTagEntryProps {
disabled?: boolean
isPreview?: boolean
previewValue?: any
previewContextValues?: Record<string, unknown>
}
/**
@@ -56,6 +57,7 @@ export function DocumentTagEntry({
disabled = false,
isPreview = false,
previewValue,
previewContextValues,
}: DocumentTagEntryProps) {
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlock.id)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
@@ -74,8 +76,12 @@ export function DocumentTagEntry({
disabled,
})
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
const knowledgeBaseId = knowledgeBaseIdValue || null
const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId')
const knowledgeBaseIdValue = previewContextValues?.knowledgeBaseId ?? knowledgeBaseIdFromStore
const knowledgeBaseId =
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
? knowledgeBaseIdValue
: null
const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const emitTagSelection = useTagSelection(blockId, subBlock.id)
@@ -131,11 +137,16 @@ export function DocumentTagEntry({
}
/**
* Removes a tag by ID (prevents removing the last tag)
* Removes a tag by ID, or resets it if it's the last one
*/
const removeTag = (id: string) => {
if (isReadOnly || tags.length === 1) return
updateTags(tags.filter((t) => t.id !== id))
if (isReadOnly) return
if (tags.length === 1) {
// Reset the last tag instead of removing it
updateTags([createDefaultTag()])
} else {
updateTags(tags.filter((t) => t.id !== id))
}
}
/**
@@ -222,6 +233,7 @@ export function DocumentTagEntry({
/**
* Renders the tag header with name, badge, and action buttons
* Shows tag name only when collapsed (as summary), generic label when expanded
*/
const renderTagHeader = (tag: DocumentTag, index: number) => (
<div
@@ -230,9 +242,11 @@ export function DocumentTagEntry({
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{tag.tagName || `Tag ${index + 1}`}
{tag.collapsed ? tag.tagName || `Tag ${index + 1}` : `Tag ${index + 1}`}
</span>
{tag.tagName && <Badge size='sm'>{FIELD_TYPE_LABELS[tag.fieldType] || 'Text'}</Badge>}
{tag.collapsed && tag.tagName && (
<Badge size='sm'>{FIELD_TYPE_LABELS[tag.fieldType] || 'Text'}</Badge>
)}
</div>
<div className='flex items-center gap-[8px] pl-[8px]' onClick={(e) => e.stopPropagation()}>
<Button
@@ -247,7 +261,7 @@ export function DocumentTagEntry({
<Button
variant='ghost'
onClick={() => removeTag(tag.id)}
disabled={isReadOnly || tags.length === 1}
disabled={isReadOnly}
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
>
<Trash className='h-[14px] w-[14px]' />
@@ -341,7 +355,7 @@ export function DocumentTagEntry({
const tagOptions: ComboboxOption[] = selectableTags.map((t) => ({
value: t.displayName,
label: `${t.displayName} (${FIELD_TYPE_LABELS[t.fieldType] || 'Text'})`,
label: t.displayName,
}))
return (

View File

@@ -40,6 +40,7 @@ interface KnowledgeTagFiltersProps {
disabled?: boolean
isPreview?: boolean
previewValue?: string | null
previewContextValues?: Record<string, unknown>
}
/**
@@ -60,14 +61,19 @@ export function KnowledgeTagFilters({
disabled = false,
isPreview = false,
previewValue,
previewContextValues,
}: KnowledgeTagFiltersProps) {
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
const emitTagSelection = useTagSelection(blockId, subBlock.id)
const valueInputRefs = useRef<Record<string, HTMLInputElement>>({})
const overlayRefs = useRef<Record<string, HTMLDivElement>>({})
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
const knowledgeBaseId = knowledgeBaseIdValue || null
const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId')
const knowledgeBaseIdValue = previewContextValues?.knowledgeBaseId ?? knowledgeBaseIdFromStore
const knowledgeBaseId =
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
? knowledgeBaseIdValue
: null
const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
@@ -123,11 +129,16 @@ export function KnowledgeTagFilters({
}
/**
* Removes a filter by ID (prevents removing the last filter)
* Removes a filter by ID, or resets it if it's the last one
*/
const removeFilter = (id: string) => {
if (isReadOnly || filters.length === 1) return
updateFilters(filters.filter((f) => f.id !== id))
if (isReadOnly) return
if (filters.length === 1) {
// Reset the last filter instead of removing it
updateFilters([createDefaultFilter()])
} else {
updateFilters(filters.filter((f) => f.id !== id))
}
}
/**
@@ -215,6 +226,7 @@ export function KnowledgeTagFilters({
/**
* Renders the filter header with name, badge, and action buttons
* Shows tag name only when collapsed (as summary), generic label when expanded
*/
const renderFilterHeader = (filter: TagFilter, index: number) => (
<div
@@ -223,9 +235,11 @@ export function KnowledgeTagFilters({
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{filter.tagName || `Filter ${index + 1}`}
{filter.collapsed ? filter.tagName || `Filter ${index + 1}` : `Filter ${index + 1}`}
</span>
{filter.tagName && <Badge size='sm'>{FIELD_TYPE_LABELS[filter.fieldType] || 'Text'}</Badge>}
{filter.collapsed && filter.tagName && (
<Badge size='sm'>{FIELD_TYPE_LABELS[filter.fieldType] || 'Text'}</Badge>
)}
</div>
<div className='flex items-center gap-[8px] pl-[8px]' onClick={(e) => e.stopPropagation()}>
<Button variant='ghost' onClick={addFilter} disabled={isReadOnly} className='h-auto p-0'>
@@ -235,7 +249,7 @@ export function KnowledgeTagFilters({
<Button
variant='ghost'
onClick={() => removeFilter(filter.id)}
disabled={isReadOnly || filters.length === 1}
disabled={isReadOnly}
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
>
<Trash className='h-[14px] w-[14px]' />
@@ -324,7 +338,7 @@ export function KnowledgeTagFilters({
const renderFilterContent = (filter: TagFilter) => {
const tagOptions: ComboboxOption[] = tagDefinitions.map((tag) => ({
value: tag.displayName,
label: `${tag.displayName} (${FIELD_TYPE_LABELS[tag.fieldType] || 'Text'})`,
label: tag.displayName,
}))
const operators = getOperatorsForFieldType(filter.fieldType)

View File

@@ -35,6 +35,7 @@ import {
Code,
FileSelectorInput,
FileUpload,
FolderSelectorInput,
LongInput,
ProjectSelectorInput,
SheetSelectorInput,
@@ -45,7 +46,9 @@ import {
TimeInput,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components'
import { DocumentSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-selector/document-selector'
import { DocumentTagEntry } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry'
import { KnowledgeBaseSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector'
import { KnowledgeTagFilters } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters'
import {
type CustomTool,
CustomToolModal,
@@ -75,6 +78,13 @@ import {
isPasswordParameter,
type ToolParameterConfig,
} from '@/tools/params'
import {
buildCanonicalIndex,
buildPreviewContextValues,
type CanonicalIndex,
evaluateSubBlockCondition,
type SubBlockCondition,
} from '@/tools/params-resolver'
const logger = createLogger('ToolInput')
@@ -304,6 +314,42 @@ function SheetSelectorSyncWrapper({
)
}
function FolderSelectorSyncWrapper({
blockId,
paramId,
value,
onChange,
uiComponent,
disabled,
previewContextValues,
}: {
blockId: string
paramId: string
value: string
onChange: (value: string) => void
uiComponent: any
disabled: boolean
previewContextValues?: Record<string, any>
}) {
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
<FolderSelectorInput
blockId={blockId}
subBlock={{
id: paramId,
type: 'folder-selector' as const,
title: paramId,
serviceId: uiComponent.serviceId,
requiredScopes: uiComponent.requiredScopes || [],
placeholder: uiComponent.placeholder,
dependsOn: uiComponent.dependsOn,
}}
disabled={disabled}
/>
</GenericSyncWrapper>
)
}
function KnowledgeBaseSelectorSyncWrapper({
blockId,
paramId,
@@ -342,6 +388,7 @@ function DocumentSelectorSyncWrapper({
onChange,
uiComponent,
disabled,
previewContextValues,
}: {
blockId: string
paramId: string
@@ -349,6 +396,7 @@ function DocumentSelectorSyncWrapper({
onChange: (value: string) => void
uiComponent: any
disabled: boolean
previewContextValues?: Record<string, any>
}) {
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
@@ -361,6 +409,67 @@ function DocumentSelectorSyncWrapper({
dependsOn: ['knowledgeBaseId'],
}}
disabled={disabled}
previewContextValues={previewContextValues}
/>
</GenericSyncWrapper>
)
}
function DocumentTagEntrySyncWrapper({
blockId,
paramId,
value,
onChange,
disabled,
previewContextValues,
}: {
blockId: string
paramId: string
value: string
onChange: (value: string) => void
disabled: boolean
previewContextValues?: Record<string, any>
}) {
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
<DocumentTagEntry
blockId={blockId}
subBlock={{
id: paramId,
type: 'document-tag-entry',
}}
disabled={disabled}
previewContextValues={previewContextValues}
/>
</GenericSyncWrapper>
)
}
function KnowledgeTagFiltersSyncWrapper({
blockId,
paramId,
value,
onChange,
disabled,
previewContextValues,
}: {
blockId: string
paramId: string
value: string
onChange: (value: string) => void
disabled: boolean
previewContextValues?: Record<string, any>
}) {
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
<KnowledgeTagFilters
blockId={blockId}
subBlock={{
id: paramId,
type: 'knowledge-tag-filters',
}}
disabled={disabled}
previewContextValues={previewContextValues}
/>
</GenericSyncWrapper>
)
@@ -497,11 +606,15 @@ function CheckboxListSyncWrapper({
}
function ComboboxSyncWrapper({
blockId,
paramId,
value,
onChange,
uiComponent,
disabled,
}: {
blockId: string
paramId: string
value: string
onChange: (value: string) => void
uiComponent: any
@@ -512,13 +625,15 @@ function ComboboxSyncWrapper({
)
return (
<Combobox
options={options}
value={value}
onChange={onChange}
placeholder={uiComponent.placeholder || 'Select option'}
disabled={disabled}
/>
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
<Combobox
options={options}
value={value}
onChange={onChange}
placeholder={uiComponent.placeholder || 'Select option'}
disabled={disabled}
/>
</GenericSyncWrapper>
)
}
@@ -597,6 +712,8 @@ function SlackSelectorSyncWrapper({
}
function WorkflowSelectorSyncWrapper({
blockId,
paramId,
value,
onChange,
uiComponent,
@@ -604,6 +721,8 @@ function WorkflowSelectorSyncWrapper({
workspaceId,
currentWorkflowId,
}: {
blockId: string
paramId: string
value: string
onChange: (value: string) => void
uiComponent: any
@@ -623,15 +742,17 @@ function WorkflowSelectorSyncWrapper({
}))
return (
<Combobox
options={options}
value={value}
onChange={onChange}
placeholder={uiComponent.placeholder || 'Select workflow'}
disabled={disabled || isLoading}
searchable
searchPlaceholder='Search workflows...'
/>
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
<Combobox
options={options}
value={value}
onChange={onChange}
placeholder={uiComponent.placeholder || 'Select workflow'}
disabled={disabled || isLoading}
searchable
searchPlaceholder='Search workflows...'
/>
</GenericSyncWrapper>
)
}
@@ -1792,57 +1913,13 @@ export function ToolInput({
return toolParams?.toolConfig?.oauth
}
/**
* Evaluates parameter conditions to determine if a parameter should be visible.
*
* @remarks
* Supports field value matching with arrays, negation via `not`, and
* compound conditions via `and`. Used for conditional parameter visibility.
*
* @param param - The parameter configuration with optional condition
* @param tool - The current tool instance with its parameter values
* @returns `true` if the parameter should be shown based on its condition
*/
const evaluateParameterCondition = (param: any, tool: StoredTool): boolean => {
if (!('uiComponent' in param) || !param.uiComponent?.condition) return true
const condition = param.uiComponent.condition
const currentValues: Record<string, any> = {
operation: tool.operation,
...tool.params,
}
const fieldValue = currentValues[condition.field]
let result = false
if (Array.isArray(condition.value)) {
result = condition.value.includes(fieldValue)
} else {
result = fieldValue === condition.value
}
if (condition.not) {
result = !result
}
if (condition.and) {
const andFieldValue = currentValues[condition.and.field]
let andResult = false
if (Array.isArray(condition.and.value)) {
andResult = condition.and.value.includes(andFieldValue)
} else {
andResult = andFieldValue === condition.and.value
}
if (condition.and.not) {
andResult = !andResult
}
result = result && andResult
}
return result
const currentValues: Record<string, any> = { operation: tool.operation, ...tool.params }
return evaluateSubBlockCondition(
param.uiComponent.condition as SubBlockCondition,
currentValues
)
}
/**
@@ -1961,7 +2038,7 @@ export function ToolInput({
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
previewContextValues={currentToolParams as any}
previewContextValues={currentToolParams}
selectorType='channel-selector'
/>
)
@@ -1975,7 +2052,7 @@ export function ToolInput({
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
previewContextValues={currentToolParams as any}
previewContextValues={currentToolParams}
selectorType='user-selector'
/>
)
@@ -1995,7 +2072,7 @@ export function ToolInput({
}}
onProjectSelect={onChange}
disabled={disabled}
previewContextValues={currentToolParams as any}
previewContextValues={currentToolParams}
/>
)
@@ -2020,7 +2097,7 @@ export function ToolInput({
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
previewContextValues={currentToolParams as any}
previewContextValues={currentToolParams}
/>
)
@@ -2033,7 +2110,20 @@ export function ToolInput({
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
previewContextValues={currentToolParams as any}
previewContextValues={currentToolParams}
/>
)
case 'folder-selector':
return (
<FolderSelectorSyncWrapper
blockId={blockId}
paramId={param.id}
value={value}
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
previewContextValues={currentToolParams}
/>
)
@@ -2052,6 +2142,8 @@ export function ToolInput({
case 'combobox':
return (
<ComboboxSyncWrapper
blockId={blockId}
paramId={param.id}
value={value}
onChange={onChange}
uiComponent={uiComponent}
@@ -2110,6 +2202,8 @@ export function ToolInput({
case 'workflow-selector':
return (
<WorkflowSelectorSyncWrapper
blockId={blockId}
paramId={param.id}
value={value}
onChange={onChange}
uiComponent={uiComponent}
@@ -2167,6 +2261,31 @@ export function ToolInput({
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
previewContextValues={currentToolParams}
/>
)
case 'document-tag-entry':
return (
<DocumentTagEntrySyncWrapper
blockId={blockId}
paramId={param.id}
value={value}
onChange={onChange}
disabled={disabled}
previewContextValues={currentToolParams}
/>
)
case 'knowledge-tag-filters':
return (
<KnowledgeTagFiltersSyncWrapper
blockId={blockId}
paramId={param.id}
value={value}
onChange={onChange}
disabled={disabled}
previewContextValues={currentToolParams}
/>
)
@@ -2225,9 +2344,27 @@ export function ToolInput({
// Get tool parameters using the new utility with block type for UI components
const toolParams =
!isCustomTool && !isMcpTool && currentToolId
? getToolParametersConfig(currentToolId, tool.type)
? getToolParametersConfig(currentToolId, tool.type, {
operation: tool.operation,
...tool.params,
})
: null
// Build canonical index for proper dependency resolution
const toolCanonicalIndex: CanonicalIndex | null = toolBlock?.subBlocks
? buildCanonicalIndex(toolBlock.subBlocks)
: null
// Build preview context with canonical resolution
const toolContextValues = toolCanonicalIndex
? buildPreviewContextValues(tool.params || {}, {
blockType: tool.type,
subBlocks: toolBlock!.subBlocks,
canonicalIndex: toolCanonicalIndex,
values: { operation: tool.operation, ...tool.params },
})
: tool.params || {}
// For custom tools, resolve from reference (new format) or use inline (legacy)
const resolvedCustomTool = isCustomTool
? resolveCustomToolFromReference(tool, customTools)
@@ -2590,7 +2727,7 @@ export function ToolInput({
{param.required && param.visibility === 'user-only' && (
<span className='ml-1'>*</span>
)}
{(!param.required || param.visibility !== 'user-only') && (
{param.visibility === 'user-or-llm' && (
<span className='ml-[6px] text-[12px] text-[var(--text-tertiary)]'>
(optional)
</span>
@@ -2603,7 +2740,7 @@ export function ToolInput({
tool.params?.[param.id] || '',
(value) => handleParamChange(toolIndex, param.id, value),
toolIndex,
tool.params || {}
toolContextValues as Record<string, string>
)
) : (
<ShortInput

View File

@@ -535,6 +535,51 @@ Return ONLY the search query - no explanations.`,
value: ['linear_read_issues', 'linear_search_issues', 'linear_list_projects'],
},
},
// Issue filtering options for read_issues (advanced)
{
id: 'labelIds',
title: 'Label IDs',
type: 'short-input',
placeholder: 'Array of label IDs to filter by',
mode: 'advanced',
condition: {
field: 'operation',
value: 'linear_read_issues',
},
},
{
id: 'createdAfter',
title: 'Created After',
type: 'short-input',
placeholder: 'Filter issues created after this date (ISO 8601 format)',
mode: 'advanced',
condition: {
field: 'operation',
value: 'linear_read_issues',
},
},
{
id: 'updatedAfter',
title: 'Updated After',
type: 'short-input',
placeholder: 'Filter issues updated after this date (ISO 8601 format)',
mode: 'advanced',
condition: {
field: 'operation',
value: 'linear_read_issues',
},
},
{
id: 'orderBy',
title: 'Order By',
type: 'short-input',
placeholder: 'Sort order: "createdAt" or "updatedAt" (default: "updatedAt")',
mode: 'advanced',
condition: {
field: 'operation',
value: 'linear_read_issues',
},
},
// Cycle ID
{
id: 'cycleId',
@@ -2188,6 +2233,16 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
estimate: { type: 'string', description: 'Estimate points' },
query: { type: 'string', description: 'Search query' },
includeArchived: { type: 'boolean', description: 'Include archived items' },
labelIds: { type: 'array', description: 'Array of label IDs to filter by' },
createdAfter: {
type: 'string',
description: 'Filter issues created after this date (ISO 8601)',
},
updatedAfter: {
type: 'string',
description: 'Filter issues updated after this date (ISO 8601)',
},
orderBy: { type: 'string', description: 'Sort order: createdAt or updatedAt' },
cycleId: { type: 'string', description: 'Cycle identifier' },
startDate: { type: 'string', description: 'Start date' },
endDate: { type: 'string', description: 'End date' },

View File

@@ -321,6 +321,7 @@ export class AgentBlockHandler implements BlockHandler {
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
isDeployedContext: ctx.isDeployedContext,
},
},
false,
@@ -1074,6 +1075,7 @@ export class AgentBlockHandler implements BlockHandler {
workflowVariables: ctx.workflowVariables || {},
blockData,
blockNameMapping,
isDeployedContext: ctx.isDeployedContext,
})
return this.processProviderResponse(response, block, responseFormat)

View File

@@ -78,6 +78,7 @@ export class ApiBlockHandler implements BlockHandler {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
executionId: ctx.executionId,
isDeployedContext: ctx.isDeployedContext,
},
},
false,

View File

@@ -40,6 +40,7 @@ export async function evaluateConditionExpression(
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
isDeployedContext: ctx.isDeployedContext,
},
},
false,

View File

@@ -38,6 +38,7 @@ export class FunctionBlockHandler implements BlockHandler {
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
isDeployedContext: ctx.isDeployedContext,
},
},
false,

View File

@@ -66,6 +66,7 @@ export class GenericBlockHandler implements BlockHandler {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
executionId: ctx.executionId,
isDeployedContext: ctx.isDeployedContext,
},
},
false,

View File

@@ -627,6 +627,7 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
isDeployedContext: ctx.isDeployedContext,
},
blockData: blockDataWithPause,
blockNameMapping: blockNameMappingWithPause,

View File

@@ -52,7 +52,8 @@ export function useWorkflowInputFields(workflowId: string | undefined) {
queryKey: workflowKeys.inputFields(workflowId),
queryFn: () => fetchWorkflowInputFields(workflowId!),
enabled: Boolean(workflowId),
staleTime: 60 * 1000, // 1 minute cache
staleTime: 0,
refetchOnMount: 'always',
})
}

View File

@@ -318,7 +318,7 @@ export async function executeWorkflowCore(
executionId,
workspaceId: providedWorkspaceId,
userId,
isDeployedContext: triggerType !== 'manual',
isDeployedContext: !metadata.isClientSession,
onBlockStart,
onBlockComplete: wrappedOnBlockComplete,
onStream,

View File

@@ -167,6 +167,7 @@ export interface ProviderRequest {
reasoningEffort?: string
verbosity?: string
thinkingLevel?: string
isDeployedContext?: boolean
}
export const providers: Record<string, ProviderConfig> = {}

View File

@@ -30,7 +30,7 @@ import {
import type { ProviderId, ProviderToolConfig } from '@/providers/types'
import { useCustomToolsStore } from '@/stores/custom-tools/store'
import { useProvidersStore } from '@/stores/providers/store'
import { deepMergeInputMapping } from '@/tools/params'
import { mergeToolParameters } from '@/tools/params'
const logger = createLogger('ProviderUtils')
@@ -981,39 +981,14 @@ export function prepareToolExecution(
workflowVariables?: Record<string, any>
blockData?: Record<string, any>
blockNameMapping?: Record<string, string>
isDeployedContext?: boolean
}
): {
toolParams: Record<string, any>
executionParams: Record<string, any>
} {
const filteredUserParams: Record<string, any> = {}
if (tool.params) {
for (const [key, value] of Object.entries(tool.params)) {
if (value !== undefined && value !== null && value !== '') {
filteredUserParams[key] = value
}
}
}
// Start with LLM params as base
const toolParams: Record<string, any> = { ...llmArgs }
// Apply user params with special handling for inputMapping
for (const [key, userValue] of Object.entries(filteredUserParams)) {
if (key === 'inputMapping') {
// Deep merge inputMapping so LLM values fill in empty user fields
const llmInputMapping = llmArgs.inputMapping as Record<string, any> | undefined
toolParams.inputMapping = deepMergeInputMapping(llmInputMapping, userValue)
} else {
// Normal override for other params
toolParams[key] = userValue
}
}
// If LLM provided inputMapping but user didn't, ensure it's included
if (llmArgs.inputMapping && !filteredUserParams.inputMapping) {
toolParams.inputMapping = llmArgs.inputMapping
}
// Use centralized merge logic from tools/params
const toolParams = mergeToolParameters(tool.params || {}, llmArgs) as Record<string, any>
const executionParams = {
...toolParams,
@@ -1024,6 +999,9 @@ export function prepareToolExecution(
...(request.workspaceId ? { workspaceId: request.workspaceId } : {}),
...(request.chatId ? { chatId: request.chatId } : {}),
...(request.userId ? { userId: request.userId } : {}),
...(request.isDeployedContext !== undefined
? { isDeployedContext: request.isDeployedContext }
: {}),
},
}
: {}),

View File

@@ -1,4 +1,6 @@
import type { KnowledgeCreateDocumentResponse } from '@/tools/knowledge/types'
import { formatDocumentTagsForAPI, parseDocumentTags } from '@/tools/params'
import { enrichKBTagsSchema } from '@/tools/schema-enrichers'
import type { ToolConfig } from '@/tools/types'
export const knowledgeCreateDocumentTool: ToolConfig<any, KnowledgeCreateDocumentResponse> = {
@@ -26,61 +28,18 @@ export const knowledgeCreateDocumentTool: ToolConfig<any, KnowledgeCreateDocumen
visibility: 'user-or-llm',
description: 'Content of the document',
},
tag1: {
type: 'string',
documentTags: {
type: 'object',
required: false,
visibility: 'user-or-llm',
description: 'Tag 1 value for the document',
description: 'Document tags',
},
tag2: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Tag 2 value for the document',
},
tag3: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Tag 3 value for the document',
},
tag4: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Tag 4 value for the document',
},
tag5: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Tag 5 value for the document',
},
tag6: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Tag 6 value for the document',
},
tag7: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Tag 7 value for the document',
},
documentTagsData: {
type: 'array',
required: false,
visibility: 'user-or-llm',
description: 'Structured tag data with names, types, and values',
items: {
type: 'object',
properties: {
tagName: { type: 'string' },
tagValue: { type: 'string' },
tagType: { type: 'string' },
},
},
},
schemaEnrichment: {
documentTags: {
dependsOn: 'knowledgeBaseId',
enrichSchema: enrichKBTagsSchema,
},
},
@@ -118,24 +77,9 @@ export const knowledgeCreateDocumentTool: ToolConfig<any, KnowledgeCreateDocumen
const dataUri = `data:text/plain;base64,${base64Content}`
const tagData: Record<string, string> = {}
if (params.documentTags) {
let parsedTags = params.documentTags
// Handle both string (JSON) and array formats
if (typeof params.documentTags === 'string') {
try {
parsedTags = JSON.parse(params.documentTags)
} catch (error) {
parsedTags = []
}
}
if (Array.isArray(parsedTags)) {
tagData.documentTagsData = JSON.stringify(parsedTags)
}
}
// Parse document tags from various formats (object, array, JSON string)
const parsedTags = parseDocumentTags(params.documentTags)
const tagData = formatDocumentTagsForAPI(parsedTags)
const documents = [
{

View File

@@ -1,5 +1,6 @@
import type { StructuredFilter } from '@/lib/knowledge/types'
import type { KnowledgeSearchResponse } from '@/tools/knowledge/types'
import { parseTagFilters } from '@/tools/params'
import { enrichKBTagFiltersSchema } from '@/tools/schema-enrichers'
import type { ToolConfig } from '@/tools/types'
export const knowledgeSearchTool: ToolConfig<any, KnowledgeSearchResponse> = {
@@ -42,6 +43,13 @@ export const knowledgeSearchTool: ToolConfig<any, KnowledgeSearchResponse> = {
},
},
schemaEnrichment: {
tagFilters: {
dependsOn: 'knowledgeBaseId',
enrichSchema: enrichKBTagFiltersSchema,
},
},
request: {
url: () => '/api/knowledge/search',
method: 'POST',
@@ -54,40 +62,8 @@ export const knowledgeSearchTool: ToolConfig<any, KnowledgeSearchResponse> = {
// Use single knowledge base ID
const knowledgeBaseIds = [params.knowledgeBaseId]
// Parse dynamic tag filters
let structuredFilters: StructuredFilter[] = []
if (params.tagFilters) {
let tagFilters = params.tagFilters
// Handle both string (JSON) and array formats
if (typeof tagFilters === 'string') {
try {
tagFilters = JSON.parse(tagFilters)
} catch {
tagFilters = []
}
}
if (Array.isArray(tagFilters)) {
// Send full filter objects with operator support
structuredFilters = tagFilters
.filter((filter: Record<string, unknown>) => {
// For boolean, any value is valid; for others, check for non-empty string
if (filter.fieldType === 'boolean') {
return filter.tagName && filter.tagValue !== undefined
}
return filter.tagName && filter.tagValue && String(filter.tagValue).trim().length > 0
})
.map((filter: Record<string, unknown>) => ({
tagName: filter.tagName as string,
tagSlot: (filter.tagSlot as string) || '', // Will be resolved by API from tagName
fieldType: (filter.fieldType as string) || 'text',
operator: (filter.operator as string) || 'eq',
value: filter.tagValue as string | number | boolean,
valueTo: filter.valueTo as string | number | undefined,
}))
}
}
// Parse tag filters from various formats (array, JSON string)
const structuredFilters = parseTagFilters(params.tagFilters)
const requestBody = {
knowledgeBaseIds,

View File

@@ -0,0 +1,46 @@
import {
buildCanonicalIndex,
type CanonicalIndex,
evaluateSubBlockCondition,
getCanonicalValues,
isCanonicalPair,
resolveCanonicalMode,
type SubBlockCondition,
} from '@/lib/workflows/subblocks/visibility'
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
export {
buildCanonicalIndex,
type CanonicalIndex,
evaluateSubBlockCondition,
type SubBlockCondition,
}
export interface ToolParamContext {
blockType: string
subBlocks: BlockSubBlockConfig[]
canonicalIndex: CanonicalIndex
values: Record<string, unknown>
}
/**
* Build preview context values for selectors that need dependency resolution.
* Resolves canonical values so selectors get the correct credential/dependency values.
*/
export function buildPreviewContextValues(
params: Record<string, unknown>,
context: ToolParamContext
): Record<string, unknown> {
const result: Record<string, unknown> = { ...params }
for (const [canonicalId, group] of Object.entries(context.canonicalIndex.groupsById)) {
if (isCanonicalPair(group)) {
const mode = resolveCanonicalMode(group, context.values)
const { basicValue, advancedValue } = getCanonicalValues(group, context.values)
result[canonicalId] =
mode === 'advanced' ? (advancedValue ?? basicValue) : (basicValue ?? advancedValue)
}
}
return result
}

View File

@@ -1,11 +1,216 @@
import { createLogger } from '@sim/logger'
import type { StructuredFilter } from '@/lib/knowledge/types'
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
import {
evaluateSubBlockCondition,
type SubBlockCondition,
} from '@/lib/workflows/subblocks/visibility'
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
import type { ParameterVisibility, ToolConfig } from '@/tools/types'
import { getTool } from '@/tools/utils'
const logger = createLogger('ToolsParams')
type ToolParamDefinition = ToolConfig['params'][string]
/**
* Checks if a value is non-empty (not undefined, null, or empty string)
*/
export function isNonEmpty(value: unknown): boolean {
return value !== undefined && value !== null && value !== ''
}
// ============================================================================
// Tag/Value Parsing Utilities
// ============================================================================
/**
* Document tag entry format used in create_document tool
*/
export interface DocumentTagEntry {
tagName: string
value: string
}
/**
* Tag filter entry format used in search tool
*/
export interface TagFilterEntry {
tagName: string
tagSlot?: string
tagValue: string | number | boolean
fieldType?: string
operator?: string
valueTo?: string | number
}
/**
* Checks if a tag value is effectively empty (unfilled/default entry)
*/
function isEmptyTagEntry(entry: Record<string, unknown>): boolean {
if (!entry.tagName || (typeof entry.tagName === 'string' && entry.tagName.trim() === '')) {
return true
}
return false
}
/**
* Checks if a tag-based value is effectively empty (only contains default/unfilled entries).
* Works for both documentTags and tagFilters parameters in various formats.
*
* @param value - The tag value to check (can be JSON string, array, or object)
* @returns true if the value is empty or only contains unfilled entries
*/
export function isEmptyTagValue(value: unknown): boolean {
if (!value) return true
// Handle JSON string format
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
if (!Array.isArray(parsed)) return false
if (parsed.length === 0) return true
return parsed.every((entry: Record<string, unknown>) => isEmptyTagEntry(entry))
} catch {
return false
}
}
// Handle array format directly
if (Array.isArray(value)) {
if (value.length === 0) return true
return value.every((entry: Record<string, unknown>) => isEmptyTagEntry(entry))
}
// Handle object format (LLM format: { "Category": "foo", "Priority": 5 })
if (typeof value === 'object' && value !== null) {
const entries = Object.entries(value)
if (entries.length === 0) return true
return entries.every(([, val]) => val === undefined || val === null || val === '')
}
return false
}
/**
* Filters valid document tags from an array, removing empty entries
*/
function filterValidDocumentTags(tags: unknown[]): DocumentTagEntry[] {
return tags
.filter((entry): entry is Record<string, unknown> => {
if (typeof entry !== 'object' || entry === null) return false
const e = entry as Record<string, unknown>
if (!e.tagName || (typeof e.tagName === 'string' && e.tagName.trim() === '')) return false
if (e.value === undefined || e.value === null || e.value === '') return false
return true
})
.map((entry) => ({
tagName: String(entry.tagName),
value: String(entry.value),
}))
}
/**
* Parses document tags from various formats into a normalized array format.
* Used by create_document tool to handle tags from both UI and LLM sources.
*
* @param value - Document tags in object, array, or JSON string format
* @returns Normalized array of document tag entries, or empty array if invalid
*/
export function parseDocumentTags(value: unknown): DocumentTagEntry[] {
if (!value) return []
// Handle object format from LLM: { "Category": "foo", "Priority": 5 }
if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
return Object.entries(value)
.filter(([tagName, tagValue]) => {
if (!tagName || tagName.trim() === '') return false
if (tagValue === undefined || tagValue === null || tagValue === '') return false
return true
})
.map(([tagName, tagValue]) => ({
tagName,
value: String(tagValue),
}))
}
// Handle JSON string format from UI
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
if (Array.isArray(parsed)) {
return filterValidDocumentTags(parsed)
}
} catch {
// Invalid JSON, return empty
}
return []
}
// Handle array format directly
if (Array.isArray(value)) {
return filterValidDocumentTags(value)
}
return []
}
/**
* Parses tag filters from various formats into a normalized StructuredFilter array.
* Used by search tool to handle tag filters from both UI and LLM sources.
*
* @param value - Tag filters in array or JSON string format
* @returns Normalized array of structured filters, or empty array if invalid
*/
export function parseTagFilters(value: unknown): StructuredFilter[] {
if (!value) return []
let tagFilters = value
// Handle JSON string format
if (typeof tagFilters === 'string') {
try {
tagFilters = JSON.parse(tagFilters)
} catch {
return []
}
}
// Must be an array at this point
if (!Array.isArray(tagFilters)) return []
return tagFilters
.filter((filter): filter is Record<string, unknown> => {
if (typeof filter !== 'object' || filter === null) return false
const f = filter as Record<string, unknown>
if (!f.tagName || (typeof f.tagName === 'string' && f.tagName.trim() === '')) return false
if (f.fieldType === 'boolean') {
return f.tagValue !== undefined
}
if (f.tagValue === undefined || f.tagValue === null) return false
if (typeof f.tagValue === 'string' && f.tagValue.trim().length === 0) return false
return true
})
.map((filter) => ({
tagName: filter.tagName as string,
tagSlot: (filter.tagSlot as string) || '',
fieldType: (filter.fieldType as string) || 'text',
operator: (filter.operator as string) || 'eq',
value: filter.tagValue as string | number | boolean,
valueTo: filter.valueTo as string | number | undefined,
}))
}
/**
* Converts parsed document tags to the format expected by the create document API.
* Returns the documentTagsData JSON string if there are valid tags.
*/
export function formatDocumentTagsForAPI(tags: DocumentTagEntry[]): { documentTagsData?: string } {
if (tags.length === 0) return {}
return {
documentTagsData: JSON.stringify(tags),
}
}
export interface Option {
label: string
value: string
@@ -39,7 +244,13 @@ export interface UIComponentConfig {
multiple?: boolean
multiSelect?: boolean
maxSize?: number
dependsOn?: string[]
dependsOn?: string[] | { all?: string[]; any?: string[] }
/** Canonical parameter ID if this is part of a canonical group */
canonicalParamId?: string
/** The mode of the source subblock (basic/advanced/both) */
mode?: 'basic' | 'advanced' | 'both' | 'trigger'
/** The actual subblock ID this config was derived from */
actualSubBlockId?: string
}
export interface SubBlockConfig {
@@ -113,12 +324,6 @@ export interface ToolWithParameters {
let blockConfigCache: Record<string, BlockConfig> | null = null
const workflowInputFieldsCache = new Map<
string,
{ fields: Array<{ name: string; type: string }>; timestamp: number }
>()
const WORKFLOW_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
function getBlockConfigurations(): Record<string, BlockConfig> {
if (!blockConfigCache) {
try {
@@ -136,13 +341,56 @@ function getBlockConfigurations(): Record<string, BlockConfig> {
return blockConfigCache
}
function resolveSubBlockForParam(
paramId: string,
subBlocks: SubBlockConfig[],
valuesWithOperation: Record<string, unknown>,
paramType: string
): BlockSubBlockConfig | undefined {
const blockSubBlocks = subBlocks as BlockSubBlockConfig[]
// First pass: find subblock with matching condition
let fallbackMatch: BlockSubBlockConfig | undefined
for (const sb of blockSubBlocks) {
const matches = sb.id === paramId || sb.canonicalParamId === paramId
if (!matches) continue
// Remember first match as fallback (for condition-based filtering in UI)
if (!fallbackMatch) fallbackMatch = sb
if (
!sb.condition ||
evaluateSubBlockCondition(sb.condition as SubBlockCondition, valuesWithOperation)
) {
return sb
}
}
// Return fallback so its condition can be used for UI filtering
if (fallbackMatch) return fallbackMatch
// Check if boolean param is part of a checkbox-list
if (paramType === 'boolean') {
return blockSubBlocks.find(
(sb) =>
sb.type === 'checkbox-list' &&
Array.isArray(sb.options) &&
(sb.options as Array<{ id?: string }>).some((opt) => opt.id === paramId)
)
}
return undefined
}
/**
* Gets all parameters for a tool, categorized by their usage
* Also includes UI component information from block configurations
*/
export function getToolParametersConfig(
toolId: string,
blockType?: string
blockType?: string,
currentValues?: Record<string, unknown>
): ToolWithParameters | null {
try {
const toolConfig = getTool(toolId)
@@ -209,6 +457,17 @@ export function getToolParametersConfig(
blockConfig = blockConfigs[blockType] || null
}
// Build values for condition evaluation
// Operation should come from currentValues if provided, otherwise extract from toolId
const values = currentValues || {}
const valuesWithOperation = { ...values }
if (valuesWithOperation.operation === undefined) {
// Fallback: extract operation from tool ID (e.g., 'slack_message' -> 'message')
const parts = toolId.split('_')
valuesWithOperation.operation =
parts.length >= 3 ? parts.slice(2).join('_') : parts[parts.length - 1]
}
// Convert tool params to our standard format with UI component info
const allParameters: ToolParameterConfig[] = Object.entries(toolConfig.params).map(
([paramId, param]) => {
@@ -221,63 +480,21 @@ export function getToolParametersConfig(
default: param.default,
}
// Add UI component information from block config if available
if (blockConfig) {
// For multi-operation tools, find the subblock that matches both the parameter ID
// and the current tool operation
let subBlock = blockConfig.subBlocks?.find((sb: SubBlockConfig) => {
if (sb.id !== paramId) return false
// If there's a condition, check if it matches the current tool
if (sb.condition && sb.condition.field === 'operation') {
// First try exact match with full tool ID
if (sb.condition.value === toolId) return true
// Then try extracting operation from tool ID
// For tools like 'google_calendar_quick_add', extract 'quick_add'
const parts = toolId.split('_')
if (parts.length >= 3) {
// Join everything after the provider prefix (e.g., 'google_calendar_')
const operation = parts.slice(2).join('_')
if (sb.condition.value === operation) return true
}
// Fallback to last part only
const operation = parts[parts.length - 1]
return sb.condition.value === operation
}
// If no condition, it's a global parameter (like apiKey)
return !sb.condition
})
// Fallback: if no operation-specific match, find any matching parameter
if (!subBlock) {
subBlock = blockConfig.subBlocks?.find((sb: SubBlockConfig) => sb.id === paramId)
}
// Special case: Check if this boolean parameter is part of a checkbox-list
if (!subBlock && param.type === 'boolean' && blockConfig) {
// Look for a checkbox-list that includes this parameter as an option
const checkboxListBlock = blockConfig.subBlocks?.find(
(sb: SubBlockConfig) =>
sb.type === 'checkbox-list' &&
Array.isArray(sb.options) &&
sb.options.some((opt: any) => opt.id === paramId)
)
if (checkboxListBlock) {
subBlock = checkboxListBlock
}
}
const subBlock = resolveSubBlockForParam(
paramId,
blockConfig.subBlocks || [],
valuesWithOperation,
param.type
)
if (subBlock) {
toolParam.uiComponent = {
type: subBlock.type,
options: subBlock.options,
options: subBlock.options as Option[] | undefined,
placeholder: subBlock.placeholder,
password: subBlock.password,
condition: subBlock.condition,
condition: subBlock.condition as ComponentCondition | undefined,
title: subBlock.title,
value: subBlock.value,
serviceId: subBlock.serviceId,
@@ -290,10 +507,13 @@ export function getToolParametersConfig(
integer: subBlock.integer,
language: subBlock.language,
generationType: subBlock.generationType,
acceptedTypes: subBlock.acceptedTypes,
acceptedTypes: subBlock.acceptedTypes ? [subBlock.acceptedTypes] : undefined,
multiple: subBlock.multiple,
maxSize: subBlock.maxSize,
dependsOn: subBlock.dependsOn,
canonicalParamId: subBlock.canonicalParamId,
mode: subBlock.mode,
actualSubBlockId: subBlock.id,
}
}
}
@@ -396,20 +616,41 @@ export async function createLLMToolSchema(
// Only include parameters that the LLM should/can provide
for (const [paramId, param] of Object.entries(toolConfig.params)) {
// Check if this param has schema enrichment config
const enrichmentConfig = toolConfig.schemaEnrichment?.[paramId]
// Special handling for workflow_executor's inputMapping parameter
// Always include in LLM schema so LLM can provide dynamic input values
// even if user has configured empty/partial inputMapping in the UI
const isWorkflowInputMapping =
toolConfig.id === 'workflow_executor' && paramId === 'inputMapping'
if (!isWorkflowInputMapping) {
const isUserProvided =
userProvidedParams[paramId] !== undefined &&
userProvidedParams[paramId] !== null &&
userProvidedParams[paramId] !== ''
// Parameters with enrichment config are treated specially:
// - Include them if dependency value is available (even if normally hidden)
// - Skip them if dependency value is not available
if (enrichmentConfig) {
const dependencyValue = userProvidedParams[enrichmentConfig.dependsOn] as string
if (!dependencyValue) {
continue
}
const propertySchema = buildParameterSchema(toolConfig.id, paramId, param)
const enrichedSchema = await enrichmentConfig.enrichSchema(dependencyValue)
if (enrichedSchema) {
Object.assign(propertySchema, enrichedSchema)
schema.properties[paramId] = propertySchema
if (param.required) {
schema.required.push(paramId)
}
}
continue
}
if (!isWorkflowInputMapping) {
// Skip parameters that user has already provided
if (isUserProvided) {
if (isNonEmpty(userProvidedParams[paramId])) {
continue
}
@@ -479,18 +720,12 @@ async function applyDynamicSchemaForWorkflow(
}
/**
* Helper function to fetch workflow input fields with caching
* Fetches workflow input fields from the API.
* No local caching - relies on React Query caching on the client side.
*/
async function fetchWorkflowInputFields(
workflowId: string
): Promise<Array<{ name: string; type: string }>> {
const cached = workflowInputFieldsCache.get(workflowId)
const now = Date.now()
if (cached && now - cached.timestamp < WORKFLOW_CACHE_TTL) {
return cached.fields
}
try {
const { buildAuthHeaders, buildAPIUrl } = await import('@/executor/utils/http')
@@ -503,10 +738,7 @@ async function fetchWorkflowInputFields(
}
const { data } = await response.json()
const fields = extractInputFieldsFromBlocks(data?.state?.blocks)
workflowInputFieldsCache.set(workflowId, { fields, timestamp: now })
return fields
return extractInputFieldsFromBlocks(data?.state?.blocks)
} catch (error) {
logger.error('Error fetching workflow input fields:', error)
return []
@@ -591,8 +823,7 @@ export function deepMergeInputMapping(
for (const [key, userValue] of Object.entries(parsedUserMapping)) {
// Only override LLM value if user provided a non-empty value
// Note: Using strict inequality (!==) so 0 and false are correctly preserved
if (userValue !== undefined && userValue !== null && userValue !== '') {
if (isNonEmpty(userValue)) {
merged[key] = userValue
}
}
@@ -612,11 +843,15 @@ export function mergeToolParameters(
userProvidedParams: Record<string, unknown>,
llmGeneratedParams: Record<string, unknown>
): Record<string, unknown> {
// Filter out empty strings from user-provided params
// Filter out empty and effectively-empty values from user-provided params
// so that cleared fields don't override LLM values
const filteredUserParams: Record<string, unknown> = {}
for (const [key, value] of Object.entries(userProvidedParams)) {
if (value !== undefined && value !== null && value !== '') {
if (isNonEmpty(value)) {
// Skip tag-based params if they're effectively empty (only default/unfilled entries)
if ((key === 'documentTags' || key === 'tagFilters') && isEmptyTagValue(value)) {
continue
}
filteredUserParams[key] = value
}
}
@@ -664,11 +899,7 @@ export function filterSchemaForLLM(
// Remove user-provided parameters from the schema
Object.keys(userProvidedParams).forEach((paramKey) => {
if (
userProvidedParams[paramKey] !== undefined &&
userProvidedParams[paramKey] !== null &&
userProvidedParams[paramKey] !== ''
) {
if (isNonEmpty(userProvidedParams[paramKey])) {
delete filteredProperties[paramKey]
const reqIndex = filteredRequired.indexOf(paramKey)
if (reqIndex > -1) {

View File

@@ -0,0 +1,127 @@
import { createLogger } from '@sim/logger'
const logger = createLogger('SchemaEnrichers')
interface TagDefinition {
id: string
tagSlot: string
displayName: string
fieldType: string
}
/**
* Maps KB field types to JSON schema types
*/
function mapFieldTypeToSchemaType(fieldType: string): string {
switch (fieldType) {
case 'number':
return 'number'
case 'boolean':
return 'boolean'
case 'date':
case 'text':
default:
return 'string'
}
}
/**
* Fetches tag definitions from knowledge base
*/
async function fetchTagDefinitions(knowledgeBaseId: string): Promise<TagDefinition[]> {
try {
const { buildAuthHeaders, buildAPIUrl } = await import('@/executor/utils/http')
const headers = await buildAuthHeaders()
const url = buildAPIUrl(`/api/knowledge/${knowledgeBaseId}/tag-definitions`)
logger.info(`Fetching tag definitions for KB ${knowledgeBaseId} from ${url.toString()}`)
const response = await fetch(url.toString(), { headers })
if (!response.ok) {
logger.warn(`Failed to fetch tag definitions for KB ${knowledgeBaseId}: ${response.status}`)
return []
}
const result = await response.json()
const tagDefinitions = result.data || []
logger.info(`Found ${tagDefinitions.length} tag definitions for KB ${knowledgeBaseId}`)
return tagDefinitions
} catch (error) {
logger.error('Failed to fetch tag definitions:', error)
return []
}
}
/**
* Fetches KB tag definitions and builds a schema for LLM consumption.
* Returns an object schema where each property is a tag the LLM can set.
*/
export async function enrichKBTagsSchema(knowledgeBaseId: string): Promise<{
type: string
properties?: Record<string, { type: string; description?: string }>
description?: string
required?: string[]
} | null> {
const tagDefinitions = await fetchTagDefinitions(knowledgeBaseId)
if (tagDefinitions.length === 0) {
return null
}
const properties: Record<string, { type: string; description?: string }> = {}
const tagDescriptions: string[] = []
for (const def of tagDefinitions) {
const schemaType = mapFieldTypeToSchemaType(def.fieldType)
properties[def.displayName] = {
type: schemaType,
description: `${def.fieldType} tag`,
}
tagDescriptions.push(`${def.displayName} (${def.fieldType})`)
}
return {
type: 'object',
properties,
description: `Document tags. Available tags: ${tagDescriptions.join(', ')}`,
}
}
/**
* Fetches KB tag definitions and builds a schema for tag filters.
* Returns an array schema where each item is a filter with tagName and tagValue.
*/
export async function enrichKBTagFiltersSchema(knowledgeBaseId: string): Promise<{
type: string
items?: Record<string, unknown>
description?: string
} | null> {
const tagDefinitions = await fetchTagDefinitions(knowledgeBaseId)
if (tagDefinitions.length === 0) {
return null
}
const tagDescriptions = tagDefinitions.map((def) => `${def.displayName} (${def.fieldType})`)
return {
type: 'array',
items: {
type: 'object',
properties: {
tagName: {
type: 'string',
description: `Name of the tag to filter by. Available: ${tagDescriptions.join(', ')}`,
},
tagValue: {
type: 'string',
description: 'Value to filter by',
},
},
required: ['tagName', 'tagValue'],
},
description: `Tag filters for search. Available tags: ${tagDescriptions.join(', ')}`,
}
}

View File

@@ -110,6 +110,12 @@ export interface ToolConfig<P = any, R = any> {
* If provided, this will be called instead of making an HTTP request.
*/
directExecution?: (params: P) => Promise<ToolResponse>
/**
* Optional dynamic schema enrichment for specific params.
* Maps param IDs to their enrichment configuration.
*/
schemaEnrichment?: Record<string, SchemaEnrichmentConfig>
}
export interface TableRow {
@@ -137,3 +143,19 @@ export interface ToolFileData {
url?: string // URL to download file from
size?: number
}
/**
* Configuration for dynamically enriching a parameter's schema at runtime.
* Used when a parameter's schema depends on runtime values (e.g., KB tags, workflow inputs).
*/
export interface SchemaEnrichmentConfig {
/** The param ID that this enrichment depends on (e.g., 'knowledgeBaseId', 'workflowId') */
dependsOn: string
/** Function to fetch and build dynamic schema based on the dependency value */
enrichSchema: (dependencyValue: string) => Promise<{
type: string
properties?: Record<string, { type: string; description?: string }>
description?: string
required?: string[]
} | null>
}

View File

@@ -16,10 +16,42 @@ describe('workflowExecutorTool', () => {
expect(result).toEqual({
input: { firstName: 'John', lastName: 'Doe', age: 30 },
triggerType: 'api',
useDraftState: true,
})
})
it.concurrent('should use deployed state when isDeployedContext is true', () => {
const params = {
workflowId: 'test-workflow-id',
inputMapping: { name: 'Test' },
_context: { isDeployedContext: true },
}
const result = buildBody(params)
expect(result).toEqual({
input: { name: 'Test' },
triggerType: 'api',
useDraftState: false,
})
})
it.concurrent('should use draft state when isDeployedContext is false', () => {
const params = {
workflowId: 'test-workflow-id',
inputMapping: { name: 'Test' },
_context: { isDeployedContext: false },
}
const result = buildBody(params)
expect(result).toEqual({
input: { name: 'Test' },
triggerType: 'api',
useDraftState: true,
})
})
it.concurrent('should parse JSON string inputMapping (UI-provided via tool-input)', () => {
const params = {
workflowId: 'test-workflow-id',
@@ -31,7 +63,7 @@ describe('workflowExecutorTool', () => {
expect(result).toEqual({
input: { firstName: 'John', lastName: 'Doe' },
triggerType: 'api',
useDraftState: false,
useDraftState: true,
})
})
@@ -46,7 +78,7 @@ describe('workflowExecutorTool', () => {
expect(result).toEqual({
input: { user: { name: 'John', email: 'john@example.com' }, count: 5 },
triggerType: 'api',
useDraftState: false,
useDraftState: true,
})
})
@@ -61,7 +93,7 @@ describe('workflowExecutorTool', () => {
expect(result).toEqual({
input: { tags: ['a', 'b', 'c'], ids: [1, 2, 3] },
triggerType: 'api',
useDraftState: false,
useDraftState: true,
})
})
@@ -76,7 +108,7 @@ describe('workflowExecutorTool', () => {
expect(result).toEqual({
input: {},
triggerType: 'api',
useDraftState: false,
useDraftState: true,
})
})
@@ -91,7 +123,7 @@ describe('workflowExecutorTool', () => {
expect(result).toEqual({
input: {},
triggerType: 'api',
useDraftState: false,
useDraftState: true,
})
})
@@ -106,7 +138,7 @@ describe('workflowExecutorTool', () => {
expect(result).toEqual({
input: {},
triggerType: 'api',
useDraftState: false,
useDraftState: true,
})
})
@@ -121,7 +153,7 @@ describe('workflowExecutorTool', () => {
expect(result).toEqual({
input: {},
triggerType: 'api',
useDraftState: false,
useDraftState: true,
})
})
@@ -136,7 +168,7 @@ describe('workflowExecutorTool', () => {
expect(result).toEqual({
input: {},
triggerType: 'api',
useDraftState: false,
useDraftState: true,
})
})
@@ -151,7 +183,7 @@ describe('workflowExecutorTool', () => {
expect(result).toEqual({
input: {},
triggerType: 'api',
useDraftState: false,
useDraftState: true,
})
})
@@ -166,7 +198,7 @@ describe('workflowExecutorTool', () => {
expect(result).toEqual({
input: { message: 'Hello\nWorld', path: 'C:\\Users' },
triggerType: 'api',
useDraftState: false,
useDraftState: true,
})
})
@@ -181,7 +213,7 @@ describe('workflowExecutorTool', () => {
expect(result).toEqual({
input: { greeting: 'こんにちは', emoji: '👋' },
triggerType: 'api',
useDraftState: false,
useDraftState: true,
})
})
@@ -196,7 +228,7 @@ describe('workflowExecutorTool', () => {
expect(result).toEqual({
input: { data: '{"nested": "json"}' },
triggerType: 'api',
useDraftState: false,
useDraftState: true,
})
})
})

View File

@@ -42,10 +42,12 @@ export const workflowExecutorTool: ToolConfig<
inputData = {}
}
}
// Use draft state for manual runs (not deployed), deployed state for deployed runs
const isDeployedContext = params._context?.isDeployedContext
return {
input: inputData,
triggerType: 'api',
useDraftState: false,
useDraftState: !isDeployedContext,
}
},
},

View File

@@ -4,6 +4,13 @@ export interface WorkflowExecutorParams {
workflowId: string
/** Can be a JSON string (from tool-input UI) or an object (from LLM args) */
inputMapping?: Record<string, any> | string
/** Execution context passed by handlers */
_context?: {
workflowId?: string
workspaceId?: string
executionId?: string
isDeployedContext?: boolean
}
}
export interface WorkflowExecutorResponse extends ToolResponse {