d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
1134 lines
34 KiB
TypeScript
1134 lines
34 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { isRecordLike } from '@sim/utils/object'
|
|
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
|
|
import {
|
|
buildCanonicalIndex,
|
|
type CanonicalModeOverrides,
|
|
evaluateSubBlockCondition,
|
|
isCanonicalPair,
|
|
isSubBlockHidden,
|
|
isTriggerModeSubBlock,
|
|
resolveCanonicalMode,
|
|
type SubBlockCondition,
|
|
} from '@/lib/workflows/subblocks/visibility'
|
|
import type {
|
|
BlockConfig as AppBlockConfig,
|
|
SubBlockConfig as BlockSubBlockConfig,
|
|
GenerationType,
|
|
} from '@/blocks/types'
|
|
import { safeAssign } from '@/tools/safe-assign'
|
|
import { isEmptyTagValue } from '@/tools/shared/tags'
|
|
import type { OAuthConfig, 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
|
|
// ============================================================================
|
|
|
|
interface Option {
|
|
label: string
|
|
value: string
|
|
}
|
|
|
|
interface ComponentCondition {
|
|
field: string
|
|
value: string | number | boolean | Array<string | number | boolean>
|
|
not?: boolean
|
|
}
|
|
|
|
interface UIComponentConfig {
|
|
type: string
|
|
options?: Option[]
|
|
placeholder?: string
|
|
password?: boolean
|
|
condition?: ComponentCondition
|
|
title?: string
|
|
value?: unknown
|
|
serviceId?: string
|
|
selectorKey?: BlockSubBlockConfig['selectorKey']
|
|
requiredScopes?: string[]
|
|
mimeType?: string
|
|
columns?: string[]
|
|
min?: number
|
|
max?: number
|
|
step?: number
|
|
integer?: boolean
|
|
language?: string
|
|
generationType?: string
|
|
acceptedTypes?: string[]
|
|
multiple?: boolean
|
|
multiSelect?: boolean
|
|
maxSize?: number
|
|
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' | 'trigger-advanced'
|
|
/** The actual subblock ID this config was derived from */
|
|
actualSubBlockId?: string
|
|
/** Wand configuration for AI assistance */
|
|
wandConfig?: {
|
|
enabled: boolean
|
|
prompt: string
|
|
generationType?: GenerationType
|
|
placeholder?: string
|
|
maintainHistory?: boolean
|
|
}
|
|
}
|
|
|
|
interface SubBlockConfig {
|
|
id: string
|
|
type: string
|
|
title?: string
|
|
options?: Option[]
|
|
placeholder?: string
|
|
password?: boolean
|
|
condition?: ComponentCondition
|
|
value?: unknown
|
|
serviceId?: string
|
|
requiredScopes?: string[]
|
|
mimeType?: string
|
|
columns?: string[]
|
|
min?: number
|
|
max?: number
|
|
step?: number
|
|
integer?: boolean
|
|
language?: string
|
|
generationType?: string
|
|
acceptedTypes?: string[]
|
|
multiple?: boolean
|
|
maxSize?: number
|
|
dependsOn?: string[]
|
|
}
|
|
|
|
type ToolInputBlockConfig = Pick<AppBlockConfig, 'type' | 'subBlocks' | 'tools'>
|
|
|
|
interface SchemaProperty {
|
|
type: string
|
|
description: string
|
|
items?: Record<string, any>
|
|
properties?: Record<string, SchemaProperty>
|
|
required?: string[]
|
|
}
|
|
|
|
export interface ToolSchema {
|
|
type: 'object'
|
|
properties: Record<string, SchemaProperty>
|
|
required: string[]
|
|
}
|
|
|
|
export interface UserToolSchemaOptions {
|
|
surface?: 'default' | 'copilot'
|
|
}
|
|
|
|
export interface LLMToolSchemaResult {
|
|
schema: ToolSchema
|
|
enrichedDescription?: string
|
|
}
|
|
|
|
export interface ValidationResult {
|
|
valid: boolean
|
|
missingParams: string[]
|
|
}
|
|
|
|
export interface ToolParameterConfig {
|
|
id: string
|
|
type: string
|
|
required?: boolean // Required for tool execution
|
|
visibility?: ParameterVisibility // Controls who can/must provide this parameter
|
|
userProvided?: boolean // User filled this parameter
|
|
description?: string
|
|
default?: unknown
|
|
// UI component information from block config
|
|
uiComponent?: UIComponentConfig
|
|
}
|
|
|
|
export interface ToolWithParameters {
|
|
toolConfig: ToolConfig
|
|
allParameters: ToolParameterConfig[]
|
|
userInputParameters: ToolParameterConfig[] // Parameters shown to user
|
|
requiredParameters: ToolParameterConfig[] // Must be filled by user or LLM
|
|
optionalParameters: ToolParameterConfig[] // Nice to have, shown to user
|
|
}
|
|
|
|
let blockConfigCache: Record<string, ToolInputBlockConfig> | null = null
|
|
|
|
function getBlockConfigurations(): Record<string, ToolInputBlockConfig> {
|
|
if (!blockConfigCache) {
|
|
try {
|
|
const { getAllBlocks } = require('@/blocks')
|
|
const allBlocks = getAllBlocks()
|
|
blockConfigCache = {}
|
|
allBlocks.forEach((block: AppBlockConfig) => {
|
|
blockConfigCache![block.type] = block
|
|
})
|
|
} catch (error) {
|
|
logger.warn('Could not load block configuration:', error)
|
|
blockConfigCache = {}
|
|
}
|
|
}
|
|
return blockConfigCache
|
|
}
|
|
|
|
/**
|
|
* Gets the correct tool ID for a block operation.
|
|
*/
|
|
export function getToolIdForOperation(blockType: string, operation?: string): string | undefined {
|
|
const block = getBlockConfigurations()[blockType]
|
|
if (!block?.tools?.access) return undefined
|
|
|
|
if (block.tools.access.length === 1) {
|
|
return block.tools.access[0]
|
|
}
|
|
|
|
if (operation && block.tools.config?.tool) {
|
|
try {
|
|
return block.tools.config.tool({ operation })
|
|
} catch (error) {
|
|
logger.error('Error selecting tool for operation:', error)
|
|
}
|
|
}
|
|
|
|
if (operation && block.tools.access.includes(operation)) {
|
|
return operation
|
|
}
|
|
|
|
return block.tools.access[0]
|
|
}
|
|
|
|
function resolveSubBlockForParam(
|
|
paramId: string,
|
|
subBlocks: BlockSubBlockConfig[],
|
|
valuesWithOperation: Record<string, unknown>,
|
|
paramType: string
|
|
): BlockSubBlockConfig | undefined {
|
|
const blockSubBlocks = subBlocks
|
|
|
|
// 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,
|
|
currentValues?: Record<string, unknown>
|
|
): ToolWithParameters | null {
|
|
try {
|
|
const toolConfig = getTool(toolId)
|
|
if (!toolConfig) {
|
|
logger.warn(`Tool not found: ${toolId}`)
|
|
return null
|
|
}
|
|
|
|
// Validate that toolConfig has required properties
|
|
if (!toolConfig.params || typeof toolConfig.params !== 'object') {
|
|
logger.warn(`Tool ${toolId} has invalid params configuration`)
|
|
return null
|
|
}
|
|
|
|
// Special handling for workflow_executor tool
|
|
if (toolId === 'workflow_executor') {
|
|
const parameters: ToolParameterConfig[] = [
|
|
{
|
|
id: 'workflowId',
|
|
type: 'string',
|
|
required: true,
|
|
visibility: 'user-only',
|
|
description: 'The ID of the workflow to execute',
|
|
uiComponent: {
|
|
type: 'workflow-selector',
|
|
placeholder: 'Select workflow to execute',
|
|
selectorKey: 'sim.workflows',
|
|
},
|
|
},
|
|
{
|
|
id: 'inputMapping',
|
|
type: 'object',
|
|
required: false,
|
|
visibility: 'user-or-llm',
|
|
description: 'Map inputs to the selected workflow',
|
|
uiComponent: {
|
|
type: 'workflow-input-mapper',
|
|
title: 'Workflow Inputs',
|
|
condition: {
|
|
field: 'workflowId',
|
|
value: '',
|
|
not: true, // Show when workflowId is not empty
|
|
},
|
|
dependsOn: ['workflowId'],
|
|
},
|
|
},
|
|
]
|
|
|
|
return {
|
|
toolConfig,
|
|
allParameters: parameters,
|
|
userInputParameters: parameters.filter(
|
|
(param) => param.visibility === 'user-or-llm' || param.visibility === 'user-only'
|
|
),
|
|
requiredParameters: parameters.filter((param) => param.required),
|
|
optionalParameters: parameters.filter(
|
|
(param) => param.visibility === 'user-only' && !param.required
|
|
),
|
|
}
|
|
}
|
|
|
|
// Get block configuration for UI component information
|
|
let blockConfig: ToolInputBlockConfig | null = null
|
|
if (blockType) {
|
|
const blockConfigs = getBlockConfigurations()
|
|
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]) => {
|
|
const toolParam: ToolParameterConfig = {
|
|
id: paramId,
|
|
type: param.type,
|
|
required: param.required ?? false,
|
|
visibility: param.visibility ?? (param.required ? 'user-or-llm' : 'user-only'),
|
|
description: param.description,
|
|
default: param.default,
|
|
}
|
|
|
|
if (blockConfig) {
|
|
const subBlock = resolveSubBlockForParam(
|
|
paramId,
|
|
blockConfig.subBlocks || [],
|
|
valuesWithOperation,
|
|
param.type
|
|
)
|
|
|
|
if (subBlock) {
|
|
if (isSubBlockHidden(subBlock)) {
|
|
toolParam.visibility = 'hidden'
|
|
}
|
|
|
|
toolParam.uiComponent = {
|
|
type: subBlock.type,
|
|
options: subBlock.options as Option[] | undefined,
|
|
placeholder: subBlock.placeholder,
|
|
password: subBlock.password,
|
|
condition: subBlock.condition as ComponentCondition | undefined,
|
|
title: subBlock.title,
|
|
value: subBlock.value,
|
|
serviceId: subBlock.serviceId,
|
|
selectorKey: subBlock.selectorKey,
|
|
requiredScopes: subBlock.requiredScopes,
|
|
mimeType: subBlock.mimeType,
|
|
columns: subBlock.columns,
|
|
min: subBlock.min,
|
|
max: subBlock.max,
|
|
step: subBlock.step,
|
|
integer: subBlock.integer,
|
|
language: subBlock.language,
|
|
generationType: subBlock.generationType,
|
|
acceptedTypes: subBlock.acceptedTypes ? [subBlock.acceptedTypes] : undefined,
|
|
multiple: subBlock.multiple,
|
|
maxSize: subBlock.maxSize,
|
|
dependsOn: subBlock.dependsOn,
|
|
canonicalParamId: subBlock.canonicalParamId,
|
|
mode: subBlock.mode,
|
|
actualSubBlockId: subBlock.id,
|
|
wandConfig: subBlock.wandConfig,
|
|
}
|
|
}
|
|
}
|
|
|
|
return toolParam
|
|
}
|
|
)
|
|
|
|
// Parameters that should be shown to the user for input
|
|
const userInputParameters = allParameters.filter(
|
|
(param) => param.visibility === 'user-or-llm' || param.visibility === 'user-only'
|
|
)
|
|
|
|
// Parameters that are required (must be filled by user or LLM)
|
|
const requiredParameters = allParameters.filter((param) => param.required)
|
|
|
|
// Parameters that are optional but can be provided by user
|
|
const optionalParameters = allParameters.filter(
|
|
(param) => param.visibility === 'user-only' && !param.required
|
|
)
|
|
|
|
return {
|
|
toolConfig,
|
|
allParameters,
|
|
userInputParameters,
|
|
requiredParameters,
|
|
optionalParameters,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error getting tool parameters config:', error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a tool schema for LLM with user-provided parameters excluded
|
|
*/
|
|
function buildParameterSchema(
|
|
toolId: string,
|
|
paramId: string,
|
|
param: ToolParamDefinition,
|
|
options: UserToolSchemaOptions = {}
|
|
): SchemaProperty {
|
|
const surface = options.surface ?? 'default'
|
|
|
|
if (surface === 'copilot' && (param.type === 'file' || param.type === 'file[]')) {
|
|
return buildCopilotFileParameterSchema(param)
|
|
}
|
|
|
|
let schemaType = param.type
|
|
if (schemaType === 'json' || schemaType === 'any') {
|
|
schemaType = 'object'
|
|
}
|
|
|
|
const propertySchema: SchemaProperty = {
|
|
type: schemaType,
|
|
description: param.description || '',
|
|
}
|
|
|
|
if (param.type === 'array' && param.items) {
|
|
propertySchema.items = {
|
|
...param.items,
|
|
...(param.items.properties && {
|
|
properties: { ...param.items.properties },
|
|
}),
|
|
}
|
|
} else if (param.items) {
|
|
logger.warn(`items property ignored for non-array param "${paramId}" in tool "${toolId}"`)
|
|
}
|
|
|
|
return propertySchema
|
|
}
|
|
|
|
function buildCopilotFileParameterSchema(param: ToolParamDefinition): SchemaProperty {
|
|
const baseDescription =
|
|
param.description ||
|
|
(param.type === 'file'
|
|
? 'A file object for tool execution.'
|
|
: 'An array of file objects for tool execution.')
|
|
const resolutionDescription =
|
|
'For copilot and mothership tool calls, prefer passing canonical workspace file IDs such as "wf_123". The runtime will resolve them into full file objects before tool execution.'
|
|
|
|
const fileObjectSchema: SchemaProperty = {
|
|
type: 'object',
|
|
description: `${baseDescription} ${resolutionDescription}`,
|
|
properties: {
|
|
id: { type: 'string', description: 'Canonical workspace file ID.' },
|
|
name: { type: 'string', description: 'File name.' },
|
|
url: { type: 'string', description: 'File URL or serve path.' },
|
|
size: { type: 'number', description: 'File size in bytes.' },
|
|
type: { type: 'string', description: 'MIME type.' },
|
|
key: { type: 'string', description: 'Internal storage key.' },
|
|
context: { type: 'string', description: 'Optional file context.' },
|
|
base64: { type: 'string', description: 'Optional base64-encoded file contents.' },
|
|
},
|
|
required: ['id', 'name', 'url', 'size', 'type', 'key'],
|
|
}
|
|
|
|
if (param.type === 'file') {
|
|
return fileObjectSchema
|
|
}
|
|
|
|
return {
|
|
type: 'array',
|
|
description: `${baseDescription} ${resolutionDescription}`,
|
|
items: {
|
|
type: 'object',
|
|
description: 'A file object.',
|
|
properties: fileObjectSchema.properties,
|
|
},
|
|
}
|
|
}
|
|
|
|
export function createUserToolSchema(
|
|
toolConfig: ToolConfig,
|
|
options: UserToolSchemaOptions = {}
|
|
): ToolSchema {
|
|
const surface = options.surface ?? 'default'
|
|
const schema: ToolSchema = {
|
|
type: 'object',
|
|
properties: {},
|
|
required: [],
|
|
}
|
|
|
|
for (const [paramId, param] of Object.entries(toolConfig.params)) {
|
|
if (!param) continue
|
|
const visibility = param.visibility ?? 'user-or-llm'
|
|
if (visibility === 'hidden') {
|
|
continue
|
|
}
|
|
|
|
const propertySchema = buildParameterSchema(toolConfig.id, paramId, param, options)
|
|
schema.properties[paramId] = propertySchema
|
|
|
|
if (param.required) {
|
|
schema.required.push(paramId)
|
|
}
|
|
}
|
|
|
|
if (toolConfig.oauth?.required && surface === 'copilot') {
|
|
schema.properties.credentialId = {
|
|
type: 'string',
|
|
description:
|
|
'Credential ID to use for this OAuth tool call. Required for Copilot/Superagent execution. Get valid IDs from environment/credentials.json.',
|
|
}
|
|
schema.required.push('credentialId')
|
|
}
|
|
|
|
return schema
|
|
}
|
|
|
|
export async function createLLMToolSchema(
|
|
toolConfig: ToolConfig,
|
|
userProvidedParams: Record<string, unknown>
|
|
): Promise<LLMToolSchemaResult> {
|
|
const schema: ToolSchema = {
|
|
type: 'object',
|
|
properties: {},
|
|
required: [],
|
|
}
|
|
|
|
for (const [paramId, param] of Object.entries(toolConfig.params)) {
|
|
const enrichmentConfig = toolConfig.schemaEnrichment?.[paramId]
|
|
|
|
const isWorkflowInputMapping =
|
|
toolConfig.id === 'workflow_executor' && paramId === 'inputMapping'
|
|
|
|
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) {
|
|
safeAssign(propertySchema, enrichedSchema as Record<string, unknown>)
|
|
schema.properties[paramId] = propertySchema
|
|
|
|
if (param.required) {
|
|
schema.required.push(paramId)
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (!isWorkflowInputMapping) {
|
|
if (isNonEmpty(userProvidedParams[paramId])) {
|
|
continue
|
|
}
|
|
|
|
if (param.visibility === 'user-only') {
|
|
continue
|
|
}
|
|
|
|
if (param.visibility === 'hidden') {
|
|
continue
|
|
}
|
|
}
|
|
|
|
const propertySchema = buildParameterSchema(toolConfig.id, paramId, param)
|
|
|
|
if (isWorkflowInputMapping) {
|
|
const workflowId = userProvidedParams.workflowId as string
|
|
if (workflowId) {
|
|
await applyDynamicSchemaForWorkflow(propertySchema, workflowId)
|
|
}
|
|
}
|
|
|
|
schema.properties[paramId] = propertySchema
|
|
|
|
if ((param.visibility === 'user-or-llm' || param.visibility === 'llm-only') && param.required) {
|
|
schema.required.push(paramId)
|
|
}
|
|
}
|
|
|
|
if (toolConfig.toolEnrichment) {
|
|
const dependencyValue = userProvidedParams[toolConfig.toolEnrichment.dependsOn] as string
|
|
if (dependencyValue) {
|
|
const enriched = await toolConfig.toolEnrichment.enrichTool(
|
|
dependencyValue,
|
|
schema,
|
|
toolConfig.description
|
|
)
|
|
if (enriched) {
|
|
return {
|
|
schema: enriched.parameters as ToolSchema,
|
|
enrichedDescription: enriched.description,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { schema }
|
|
}
|
|
|
|
/**
|
|
* Apply dynamic schema enrichment for workflow_executor's inputMapping parameter
|
|
*/
|
|
async function applyDynamicSchemaForWorkflow(
|
|
propertySchema: any,
|
|
workflowId: string
|
|
): Promise<void> {
|
|
try {
|
|
const workflowInputFields = await fetchWorkflowInputFields(workflowId)
|
|
|
|
if (workflowInputFields && workflowInputFields.length > 0) {
|
|
propertySchema.type = 'object'
|
|
propertySchema.properties = {}
|
|
propertySchema.required = []
|
|
|
|
// Convert workflow input fields to JSON schema properties
|
|
for (const field of workflowInputFields) {
|
|
propertySchema.properties[field.name] = {
|
|
type: field.type || 'string',
|
|
description: field.description || `Input field: ${field.name}`,
|
|
}
|
|
propertySchema.required.push(field.name)
|
|
}
|
|
|
|
// Update description to be more specific
|
|
propertySchema.description = `Input values for the workflow. Required fields: ${workflowInputFields.map((f) => f.name).join(', ')}`
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to fetch workflow input fields for LLM schema:', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches workflow input fields from the API.
|
|
*/
|
|
async function fetchWorkflowInputFields(
|
|
workflowId: string
|
|
): Promise<Array<{ name: string; type: string; description?: string }>> {
|
|
try {
|
|
const { buildAuthHeaders, buildAPIUrl } = await import('@/executor/utils/http')
|
|
|
|
const headers = await buildAuthHeaders()
|
|
const url = buildAPIUrl(`/api/workflows/${workflowId}`)
|
|
|
|
const response = await fetch(url.toString(), { headers })
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch workflow')
|
|
}
|
|
|
|
const { data } = await response.json()
|
|
return extractInputFieldsFromBlocks(data?.state?.blocks)
|
|
} catch (error) {
|
|
logger.error('Error fetching workflow input fields:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a complete tool schema for execution with all parameters
|
|
*/
|
|
export function createExecutionToolSchema(toolConfig: ToolConfig): ToolSchema {
|
|
const schema: ToolSchema = {
|
|
type: 'object',
|
|
properties: {},
|
|
required: [],
|
|
}
|
|
|
|
Object.entries(toolConfig.params).forEach(([paramId, param]) => {
|
|
const propertySchema: any = {
|
|
type: param.type === 'json' ? 'object' : param.type,
|
|
description: param.description || '',
|
|
}
|
|
|
|
// Include items property for arrays
|
|
if (param.type === 'array' && param.items) {
|
|
propertySchema.items = {
|
|
...param.items,
|
|
...(param.items.properties && {
|
|
properties: { ...param.items.properties },
|
|
}),
|
|
}
|
|
} else if (param.items) {
|
|
logger.warn(
|
|
`items property ignored for non-array param "${paramId}" in tool "${toolConfig.id}"`
|
|
)
|
|
}
|
|
|
|
schema.properties[paramId] = propertySchema
|
|
|
|
if (param.required) {
|
|
schema.required.push(paramId)
|
|
}
|
|
})
|
|
|
|
return schema
|
|
}
|
|
|
|
/**
|
|
* Deep merges inputMapping objects, where LLM values fill in empty/missing user values.
|
|
* User-provided non-empty values take precedence.
|
|
*/
|
|
export function deepMergeInputMapping(
|
|
llmInputMapping: Record<string, unknown> | undefined,
|
|
userInputMapping: Record<string, unknown> | string | undefined
|
|
): Record<string, unknown> {
|
|
// Parse user inputMapping if it's a JSON string
|
|
let parsedUserMapping: Record<string, unknown> = {}
|
|
if (typeof userInputMapping === 'string') {
|
|
try {
|
|
const parsed = JSON.parse(userInputMapping)
|
|
if (isRecordLike(parsed)) {
|
|
parsedUserMapping = parsed
|
|
}
|
|
} catch {
|
|
// Invalid JSON, treat as empty
|
|
}
|
|
} else if (
|
|
typeof userInputMapping === 'object' &&
|
|
userInputMapping !== null &&
|
|
!Array.isArray(userInputMapping)
|
|
) {
|
|
parsedUserMapping = userInputMapping
|
|
}
|
|
|
|
// If no LLM mapping, return user mapping (or empty)
|
|
if (!llmInputMapping || typeof llmInputMapping !== 'object') {
|
|
return parsedUserMapping
|
|
}
|
|
|
|
// Deep merge: LLM values as base, user non-empty values override
|
|
// If user provides empty object {}, LLM values fill all fields (intentional)
|
|
const merged: Record<string, unknown> = { ...llmInputMapping }
|
|
|
|
for (const [key, userValue] of Object.entries(parsedUserMapping)) {
|
|
// Only override LLM value if user provided a non-empty value
|
|
if (isNonEmpty(userValue)) {
|
|
merged[key] = userValue
|
|
}
|
|
}
|
|
|
|
return merged
|
|
}
|
|
|
|
/**
|
|
* Merges user-provided parameters with LLM-generated parameters.
|
|
* User-provided parameters take precedence, but empty strings are skipped
|
|
* so that LLM-generated values are used when user clears a field.
|
|
*
|
|
* Special handling for inputMapping: deep merges so LLM can fill in
|
|
* fields that user left empty in the UI.
|
|
*/
|
|
export function mergeToolParameters(
|
|
userProvidedParams: Record<string, unknown>,
|
|
llmGeneratedParams: Record<string, unknown>
|
|
): Record<string, unknown> {
|
|
// 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 (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
|
|
}
|
|
}
|
|
|
|
// Start with LLM params as base
|
|
const result: Record<string, unknown> = { ...llmGeneratedParams }
|
|
|
|
// 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 = llmGeneratedParams.inputMapping as Record<string, unknown> | undefined
|
|
const mergedInputMapping = deepMergeInputMapping(
|
|
llmInputMapping,
|
|
userValue as Record<string, unknown> | string | undefined
|
|
)
|
|
result.inputMapping = mergedInputMapping
|
|
} else {
|
|
// Normal override for other params
|
|
result[key] = userValue
|
|
}
|
|
}
|
|
|
|
// If LLM provided inputMapping but user didn't, ensure it's included
|
|
if (llmGeneratedParams.inputMapping && !filteredUserParams.inputMapping) {
|
|
result.inputMapping = llmGeneratedParams.inputMapping
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Filters out user-provided parameters from tool schema for LLM
|
|
*/
|
|
export function filterSchemaForLLM(
|
|
originalSchema: ToolSchema,
|
|
userProvidedParams: Record<string, unknown>
|
|
): ToolSchema {
|
|
if (!originalSchema || !originalSchema.properties) {
|
|
return originalSchema
|
|
}
|
|
|
|
const filteredProperties = { ...originalSchema.properties }
|
|
const filteredRequired = [...(originalSchema.required || [])]
|
|
|
|
// Remove user-provided parameters from the schema
|
|
Object.keys(userProvidedParams).forEach((paramKey) => {
|
|
if (isNonEmpty(userProvidedParams[paramKey])) {
|
|
delete filteredProperties[paramKey]
|
|
const reqIndex = filteredRequired.indexOf(paramKey)
|
|
if (reqIndex > -1) {
|
|
filteredRequired.splice(reqIndex, 1)
|
|
}
|
|
}
|
|
})
|
|
|
|
return {
|
|
...originalSchema,
|
|
properties: filteredProperties,
|
|
required: filteredRequired,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates that all required parameters are provided
|
|
*/
|
|
export function validateToolParameters(
|
|
toolConfig: ToolConfig,
|
|
finalParams: Record<string, unknown>
|
|
): ValidationResult {
|
|
const requiredParams = Object.entries(toolConfig.params)
|
|
.filter(([_, param]) => param.required)
|
|
.map(([paramId]) => paramId)
|
|
|
|
const missingParams = requiredParams.filter(
|
|
(paramId) =>
|
|
finalParams[paramId] === undefined ||
|
|
finalParams[paramId] === null ||
|
|
finalParams[paramId] === ''
|
|
)
|
|
|
|
return {
|
|
valid: missingParams.length === 0,
|
|
missingParams,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper to check if a parameter should be treated as a password field
|
|
*/
|
|
export function isPasswordParameter(paramId: string): boolean {
|
|
const passwordFields = [
|
|
'password',
|
|
'apiKey',
|
|
'token',
|
|
'secret',
|
|
'key',
|
|
'credential',
|
|
'accessToken',
|
|
'refreshToken',
|
|
'botToken',
|
|
'authToken',
|
|
]
|
|
|
|
return passwordFields.some((field) => paramId.toLowerCase().includes(field.toLowerCase()))
|
|
}
|
|
|
|
/**
|
|
* Formats parameter IDs into human-readable labels
|
|
*/
|
|
export function formatParameterLabel(paramId: string): string {
|
|
// Special cases
|
|
if (paramId === 'apiKey') return 'API Key'
|
|
if (paramId === 'apiVersion') return 'API Version'
|
|
if (paramId === 'accessToken') return 'Access Token'
|
|
if (paramId === 'refreshToken') return 'Refresh Token'
|
|
if (paramId === 'botToken') return 'Bot Token'
|
|
|
|
// Handle underscore and hyphen separated words
|
|
if (paramId.includes('_') || paramId.includes('-')) {
|
|
return paramId
|
|
.split(/[-_]/)
|
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
.join(' ')
|
|
}
|
|
|
|
// Handle single character parameters
|
|
if (paramId.length === 1) return paramId.toUpperCase()
|
|
|
|
// Handle camelCase
|
|
if (/[A-Z]/.test(paramId)) {
|
|
const result = paramId.replace(/([A-Z])/g, ' $1')
|
|
return (
|
|
result.charAt(0).toUpperCase() +
|
|
result
|
|
.slice(1)
|
|
.replace(/ Api/g, ' API')
|
|
.replace(/ Id/g, ' ID')
|
|
.replace(/ Url/g, ' URL')
|
|
.replace(/ Uri/g, ' URI')
|
|
.replace(/ Ui/g, ' UI')
|
|
)
|
|
}
|
|
|
|
// Simple case - just capitalize first letter
|
|
return paramId.charAt(0).toUpperCase() + paramId.slice(1)
|
|
}
|
|
|
|
/**
|
|
* SubBlock IDs that control tool routing, not user-facing parameters.
|
|
* Excluded from tool-input rendering unless they have an explicit paramVisibility set.
|
|
*/
|
|
const STRUCTURAL_SUBBLOCK_IDS = new Set(['operation'])
|
|
|
|
/**
|
|
* SubBlock types that represent auth/credential inputs handled separately
|
|
* by the tool-input OAuth credential selector.
|
|
*/
|
|
const AUTH_SUBBLOCK_TYPES = new Set(['oauth-input'])
|
|
|
|
/**
|
|
* SubBlock types that should never appear in tool-input context.
|
|
*/
|
|
const EXCLUDED_SUBBLOCK_TYPES = new Set([
|
|
'tool-input',
|
|
'skill-input',
|
|
'condition-input',
|
|
'eval-input',
|
|
'webhook-config',
|
|
'schedule-info',
|
|
'input-format',
|
|
'response-format',
|
|
'mcp-server-selector',
|
|
'mcp-tool-selector',
|
|
'mcp-dynamic-args',
|
|
'input-mapping',
|
|
'variables-input',
|
|
'messages-input',
|
|
'router-input',
|
|
'text',
|
|
])
|
|
|
|
export interface SubBlocksForToolInput {
|
|
toolConfig: ToolConfig
|
|
subBlocks: BlockSubBlockConfig[]
|
|
oauthConfig?: OAuthConfig
|
|
}
|
|
|
|
/**
|
|
* Returns filtered SubBlockConfig[] for rendering in tool-input context.
|
|
* Uses subblock definitions as the primary source of UI metadata,
|
|
* getting all features (wandConfig, rich conditions, dependsOn, etc.) for free.
|
|
*
|
|
* For blocks without paramVisibility annotations, falls back to inferring
|
|
* visibility from the tool's param definitions.
|
|
*/
|
|
export function getSubBlocksForToolInput(
|
|
toolId: string,
|
|
blockType: string,
|
|
currentValues?: Record<string, unknown>,
|
|
canonicalModeOverrides?: CanonicalModeOverrides,
|
|
blockConfigOverride?: Pick<ToolInputBlockConfig, 'subBlocks'>
|
|
): SubBlocksForToolInput | null {
|
|
try {
|
|
const toolConfig = getTool(toolId)
|
|
if (!toolConfig) {
|
|
logger.warn(`Tool not found: ${toolId}`)
|
|
return null
|
|
}
|
|
|
|
const blockConfigs = getBlockConfigurations()
|
|
const blockConfig = blockConfigOverride ?? blockConfigs[blockType]
|
|
if (!blockConfig?.subBlocks?.length) {
|
|
return null
|
|
}
|
|
|
|
const allSubBlocks = blockConfig.subBlocks as BlockSubBlockConfig[]
|
|
const canonicalIndex = buildCanonicalIndex(allSubBlocks)
|
|
|
|
// Build values for condition evaluation
|
|
const values = currentValues || {}
|
|
const valuesWithOperation = { ...values }
|
|
if (valuesWithOperation.operation === undefined) {
|
|
const parts = toolId.split('_')
|
|
valuesWithOperation.operation =
|
|
parts.length >= 3 ? parts.slice(2).join('_') : parts[parts.length - 1]
|
|
}
|
|
|
|
// Build a map of tool param IDs to their resolved visibility
|
|
const toolParamVisibility: Record<string, ParameterVisibility> = {}
|
|
for (const [paramId, param] of Object.entries(toolConfig.params || {})) {
|
|
toolParamVisibility[paramId] =
|
|
param.visibility ?? (param.required ? 'user-or-llm' : 'user-only')
|
|
}
|
|
|
|
// Track which canonical groups we've already included (to avoid duplicates)
|
|
const includedCanonicalIds = new Set<string>()
|
|
|
|
const filtered: BlockSubBlockConfig[] = []
|
|
|
|
for (const sb of allSubBlocks) {
|
|
// Skip excluded types
|
|
if (EXCLUDED_SUBBLOCK_TYPES.has(sb.type)) continue
|
|
|
|
// Skip trigger-mode-only subblocks
|
|
if (isTriggerModeSubBlock(sb)) continue
|
|
|
|
// Hide tool API key fields when running on hosted Sim or when env var is set
|
|
if (isSubBlockHidden(sb)) continue
|
|
|
|
// Determine the effective param ID (canonical or subblock id)
|
|
const effectiveParamId = sb.canonicalParamId || sb.id
|
|
|
|
// Resolve paramVisibility: explicit > inferred from tool params > skip
|
|
let visibility = sb.paramVisibility
|
|
if (!visibility) {
|
|
// Infer from structural checks
|
|
if (STRUCTURAL_SUBBLOCK_IDS.has(sb.id)) {
|
|
visibility = 'hidden'
|
|
} else if (AUTH_SUBBLOCK_TYPES.has(sb.type) && sb.canonicalParamId !== 'oauthCredential') {
|
|
visibility = 'hidden'
|
|
} else if (sb.canonicalParamId === 'oauthCredential') {
|
|
visibility = 'user-only'
|
|
} else if (
|
|
sb.password &&
|
|
(sb.id === 'botToken' || sb.id === 'accessToken' || sb.id === 'apiKey')
|
|
) {
|
|
// Auth tokens without explicit paramVisibility are hidden
|
|
// (they're handled by the OAuth credential selector or structurally)
|
|
// But only if they don't have a matching tool param
|
|
if (!(sb.id in toolParamVisibility)) {
|
|
visibility = 'hidden'
|
|
} else {
|
|
visibility = toolParamVisibility[sb.id] || 'user-or-llm'
|
|
}
|
|
} else if (effectiveParamId in toolParamVisibility) {
|
|
// Fallback: infer from tool param visibility
|
|
visibility = toolParamVisibility[effectiveParamId]
|
|
} else if (sb.id in toolParamVisibility) {
|
|
visibility = toolParamVisibility[sb.id]
|
|
} else if (sb.canonicalParamId) {
|
|
visibility = 'user-or-llm'
|
|
} else {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Filter by visibility: exclude hidden and llm-only
|
|
if (visibility === 'hidden' || visibility === 'llm-only') continue
|
|
|
|
if (sb.condition && !sb.reactiveCondition) {
|
|
const conditionMet = evaluateSubBlockCondition(
|
|
sb.condition as SubBlockCondition,
|
|
valuesWithOperation
|
|
)
|
|
if (!conditionMet) continue
|
|
}
|
|
|
|
// Handle canonical pairs: only include the active mode variant
|
|
const canonicalId = canonicalIndex.canonicalIdBySubBlockId[sb.id]
|
|
if (canonicalId) {
|
|
const group = canonicalIndex.groupsById[canonicalId]
|
|
if (group && isCanonicalPair(group)) {
|
|
if (includedCanonicalIds.has(canonicalId)) continue
|
|
includedCanonicalIds.add(canonicalId)
|
|
|
|
// Determine active mode
|
|
const mode = resolveCanonicalMode(group, valuesWithOperation, canonicalModeOverrides)
|
|
if (mode === 'advanced') {
|
|
// Find the advanced variant
|
|
const advancedSb = allSubBlocks.find((s) => group.advancedIds.includes(s.id))
|
|
if (advancedSb) {
|
|
filtered.push({ ...advancedSb, paramVisibility: visibility })
|
|
}
|
|
} else {
|
|
// Include basic variant (current sb if it's the basic one)
|
|
if (group.basicId === sb.id) {
|
|
filtered.push({ ...sb, paramVisibility: visibility })
|
|
} else {
|
|
const basicSb = allSubBlocks.find((s) => s.id === group.basicId)
|
|
if (basicSb) {
|
|
filtered.push({ ...basicSb, paramVisibility: visibility })
|
|
}
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Non-canonical, non-hidden, condition-passing subblock
|
|
filtered.push({ ...sb, paramVisibility: visibility })
|
|
}
|
|
|
|
return {
|
|
toolConfig,
|
|
subBlocks: filtered,
|
|
oauthConfig: toolConfig.oauth,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error getting subblocks for tool input:', error)
|
|
return null
|
|
}
|
|
}
|