feat(agent): added workflow, kb, and function as a tool for agent block, fix keyboard nav in tool input (#2107)

* feat(agent): added workflow, kb, and function as a tool for agent block

* fix keyboard nav and keyboard selection in tool-inp

* ack PR comments

* remove custom tool changes

* fixed kb tools for agent

* cleanup
This commit is contained in:
Waleed
2025-11-24 12:16:02 -08:00
committed by GitHub
parent a5b7897b34
commit c80827f21b
16 changed files with 1181 additions and 617 deletions

View File

@@ -1,5 +1,4 @@
import type React from 'react'
import {
import React, {
createContext,
type ReactNode,
useCallback,
@@ -19,6 +18,7 @@ type CommandContextType = {
registerItem: (id: string) => void
unregisterItem: (id: string) => void
selectItem: (id: string) => void
handleKeyDown: (e: React.KeyboardEvent) => void
}
const CommandContext = createContext<CommandContextType | undefined>(undefined)
@@ -31,6 +31,11 @@ const useCommandContext = () => {
return context
}
export const useCommandKeyDown = () => {
const context = useContext(CommandContext)
return context?.handleKeyDown
}
interface CommandProps {
children: ReactNode
className?: string
@@ -71,7 +76,6 @@ export function Command({
const [items, setItems] = useState<string[]>([])
const [filteredItems, setFilteredItems] = useState<string[]>([])
// Use external searchQuery if provided, otherwise use internal state
const searchQuery = externalSearchQuery ?? internalSearchQuery
const registerItem = useCallback((id: string) => {
@@ -101,19 +105,27 @@ export function Command({
return
}
const filtered = items
.map((item) => {
const score = filter ? filter(item, searchQuery) : defaultFilter(item, searchQuery)
return { item, score }
})
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score)
.map((item) => item.item)
const filtered = items.filter((item) => {
const score = filter ? filter(item, searchQuery) : defaultFilter(item, searchQuery)
return score > 0
})
setFilteredItems(filtered)
setActiveIndex(filtered.length > 0 ? 0 : -1)
}, [searchQuery, items, filter])
useEffect(() => {
if (activeIndex >= 0 && filteredItems[activeIndex]) {
const activeElement = document.getElementById(filteredItems[activeIndex])
if (activeElement) {
activeElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
}
}
}, [activeIndex, filteredItems])
const defaultFilter = useCallback((value: string, search: string): number => {
const normalizedValue = value.toLowerCase()
const normalizedSearch = search.toLowerCase()
@@ -158,15 +170,22 @@ export function Command({
registerItem,
unregisterItem,
selectItem,
handleKeyDown,
}),
[searchQuery, activeIndex, filteredItems, registerItem, unregisterItem, selectItem]
[
searchQuery,
activeIndex,
filteredItems,
registerItem,
unregisterItem,
selectItem,
handleKeyDown,
]
)
return (
<CommandContext.Provider value={contextValue}>
<div className={cn('flex w-full flex-col', className)} onKeyDown={handleKeyDown}>
{children}
</div>
<div className={cn('flex w-full flex-col', className)}>{children}</div>
</CommandContext.Provider>
)
}

View File

@@ -1,18 +1,10 @@
import { useEffect, useMemo, useState } from 'react'
import { Check, ChevronDown, ExternalLink, Plus, RefreshCw } from 'lucide-react'
import { Button } from '@/components/emcn/components/button/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console/logger'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ExternalLink } from 'lucide-react'
import { Button, Combobox } from '@/components/emcn/components'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
getServiceIdFromScopes,
OAUTH_PROVIDERS,
type OAuthProvider,
type OAuthService,
@@ -23,16 +15,14 @@ import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/o
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('ToolCredentialSelector')
const getProviderIcon = (providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
if (!baseProviderConfig) {
return <ExternalLink className='h-4 w-4' />
return <ExternalLink className='h-3 w-3' />
}
return baseProviderConfig.icon({ className: 'h-4 w-4' })
return baseProviderConfig.icon({ className: 'h-3 w-3' })
}
const getProviderName = (providerName: OAuthProvider) => {
@@ -68,192 +58,237 @@ export function ToolCredentialSelector({
serviceId,
disabled = false,
}: ToolCredentialSelectorProps) {
const [open, setOpen] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [selectedId, setSelectedId] = useState('')
const [inputValue, setInputValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
const { activeWorkflowId } = useWorkflowRegistry()
useEffect(() => {
setSelectedId(value)
}, [value])
const selectedId = value || ''
const effectiveServiceId = useMemo(
() => serviceId || getServiceIdFromScopes(provider, requiredScopes),
[provider, requiredScopes, serviceId]
)
const effectiveProviderId = useMemo(
() => getProviderIdFromServiceId(effectiveServiceId),
[effectiveServiceId]
)
const {
data: fetchedCredentials = [],
data: credentials = [],
isFetching: credentialsLoading,
refetch: refetchCredentials,
} = useOAuthCredentials(provider, true)
} = useOAuthCredentials(effectiveProviderId, Boolean(effectiveProviderId))
const shouldFetchDetail =
Boolean(value) &&
!fetchedCredentials.some((cred) => cred.id === value) &&
Boolean(activeWorkflowId)
const selectedCredential = useMemo(
() => credentials.find((cred) => cred.id === selectedId),
[credentials, selectedId]
)
const { data: collaboratorCredentials = [], isFetching: collaboratorLoading } =
const shouldFetchForeignMeta =
Boolean(selectedId) &&
!selectedCredential &&
Boolean(activeWorkflowId) &&
Boolean(effectiveProviderId)
const { data: foreignCredentials = [], isFetching: foreignMetaLoading } =
useOAuthCredentialDetail(
shouldFetchDetail ? value : undefined,
shouldFetchForeignMeta ? selectedId : undefined,
activeWorkflowId || undefined,
shouldFetchDetail
shouldFetchForeignMeta
)
const credentials = useMemo(() => {
if (collaboratorCredentials.length === 0) {
return fetchedCredentials
}
const collaborator = collaboratorCredentials[0]
if (!collaborator) {
return fetchedCredentials
}
const alreadyIncluded = fetchedCredentials.some((cred) => cred.id === collaborator.id)
if (alreadyIncluded) {
return fetchedCredentials
}
return [collaborator, ...fetchedCredentials]
}, [fetchedCredentials, collaboratorCredentials])
const hasForeignMeta = foreignCredentials.length > 0
const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta)
const resolvedLabel = useMemo(() => {
if (selectedCredential) return selectedCredential.name
if (isForeign) return 'Saved by collaborator'
return ''
}, [selectedCredential, isForeign])
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
if (!isEditing) {
setInputValue(resolvedLabel)
}
}, [resolvedLabel, isEditing])
const invalidSelection =
Boolean(selectedId) &&
!selectedCredential &&
!hasForeignMeta &&
!credentialsLoading &&
!foreignMetaLoading
useEffect(() => {
if (!invalidSelection) return
onChange('')
}, [invalidSelection, onChange])
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, provider)
const handleOpenChange = useCallback(
(isOpen: boolean) => {
if (isOpen) {
void refetchCredentials()
}
}
},
[refetchCredentials]
)
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [refetchCredentials])
const handleSelect = (credentialId: string) => {
setSelectedId(credentialId)
onChange(credentialId)
setOpen(false)
}
const handleOAuthClose = () => {
setShowOAuthModal(false)
void refetchCredentials()
}
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
if (isOpen) {
void refetchCredentials()
}
}
const selectedCredential = credentials.find((cred) => cred.id === selectedId)
const isForeign = !!(selectedId && !selectedCredential)
// Determine if additional permissions are required for the selected credential
const hasSelection = !!selectedCredential
const hasSelection = Boolean(selectedCredential)
const missingRequiredScopes = hasSelection
? getMissingRequiredScopes(selectedCredential, requiredScopes || [])
? getMissingRequiredScopes(selectedCredential!, requiredScopes || [])
: []
const needsUpdate =
hasSelection && missingRequiredScopes.length > 0 && !disabled && !credentialsLoading
const handleSelect = useCallback(
(credentialId: string) => {
onChange(credentialId)
setIsEditing(false)
},
[onChange]
)
const handleAddCredential = useCallback(() => {
setShowOAuthModal(true)
}, [])
const comboboxOptions = useMemo(() => {
const options = credentials.map((cred) => ({
label: cred.name,
value: cred.id,
}))
if (credentials.length === 0) {
options.push({
label: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
})
}
return options
}, [credentials, provider])
const selectedCredentialProvider = selectedCredential?.provider ?? provider
const overlayContent = useMemo(() => {
if (!inputValue) return null
return (
<div className='flex w-full items-center truncate'>
<div className='mr-2 flex-shrink-0 opacity-90'>
{getProviderIcon(selectedCredentialProvider)}
</div>
<span className='truncate'>{inputValue}</span>
</div>
)
}, [inputValue, selectedCredentialProvider])
const handleComboboxChange = useCallback(
(newValue: string) => {
if (newValue === '__connect_account__') {
handleAddCredential()
return
}
const matchedCred = credentials.find((c) => c.id === newValue)
if (matchedCred) {
setInputValue(matchedCred.name)
handleSelect(newValue)
return
}
setIsEditing(true)
setInputValue(newValue)
},
[credentials, handleAddCredential, handleSelect]
)
return (
<>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='h-10 w-full min-w-0 justify-between'
disabled={disabled}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{getProviderIcon(provider)}
<span
className={
selectedCredential ? 'truncate font-normal' : 'truncate text-muted-foreground'
}
>
{selectedCredential
? selectedCredential.name
: isForeign
? 'Saved by collaborator'
: label}
</span>
</div>
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
<Command>
<CommandList>
<CommandEmpty>
{credentialsLoading || collaboratorLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading...</span>
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a {getProviderName(provider)} account to continue.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts found.</p>
</div>
)}
</CommandEmpty>
{credentials.length > 0 && (
<CommandGroup>
{credentials.map((credential) => (
<CommandItem
key={credential.id}
value={credential.id}
onSelect={() => handleSelect(credential.id)}
>
<div className='flex items-center gap-2'>
{getProviderIcon(credential.provider)}
<span className='font-normal'>{credential.name}</span>
</div>
{credential.id === selectedId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
<CommandGroup>
<CommandItem onSelect={() => setShowOAuthModal(true)}>
<div className='flex items-center gap-2'>
<Plus className='h-4 w-4' />
<span className='font-normal'>Connect {getProviderName(provider)} account</span>
</div>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Combobox
options={comboboxOptions}
value={inputValue}
selectedValue={selectedId}
onChange={handleComboboxChange}
onOpenChange={handleOpenChange}
placeholder={label}
disabled={disabled}
editable={true}
filterOptions={true}
isLoading={credentialsLoading}
overlayContent={overlayContent}
className={selectedId ? 'pl-[28px]' : ''}
/>
{needsUpdate && (
<div className='mt-2 flex items-center justify-between rounded-[6px] border border-amber-300/40 bg-amber-50/60 px-2 py-1 font-medium text-[12px] transition-colors dark:bg-amber-950/10'>
<span>Additional permissions required</span>
{/* We don't have reliable foreign detection context here; always show CTA */}
<Button onClick={() => setShowOAuthModal(true)}>Update access</Button>
{!isForeign && <Button onClick={() => setShowOAuthModal(true)}>Update access</Button>}
</div>
)}
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={handleOAuthClose}
provider={provider}
toolName={label}
requiredScopes={getCanonicalScopesForProvider(
serviceId ? getProviderIdFromServiceId(serviceId) : (provider as string)
)}
newScopes={missingRequiredScopes}
serviceId={serviceId}
/>
{showOAuthModal && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider={provider}
toolName={getProviderName(provider)}
requiredScopes={getCanonicalScopesForProvider(effectiveProviderId)}
newScopes={missingRequiredScopes}
serviceId={effectiveServiceId}
/>
)}
</>
)
}
function useCredentialRefreshTriggers(
refetchCredentials: () => Promise<unknown>,
effectiveProviderId?: string,
provider?: OAuthProvider
) {
useEffect(() => {
const refresh = () => {
void refetchCredentials()
}
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
refresh()
}
}
const handlePageShow = (event: Event) => {
if ('persisted' in event && (event as PageTransitionEvent).persisted) {
refresh()
}
}
const handleCredentialDisconnected = (event: Event) => {
const customEvent = event as CustomEvent<{ providerId?: string }>
const providerId = customEvent.detail?.providerId
if (
providerId &&
(providerId === effectiveProviderId || (provider && providerId.startsWith(provider)))
) {
refresh()
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('pageshow', handlePageShow)
window.addEventListener('credential-disconnected', handleCredentialDisconnected)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('pageshow', handlePageShow)
window.removeEventListener('credential-disconnected', handleCredentialDisconnected)
}
}, [refetchCredentials, effectiveProviderId, provider])
}

View File

@@ -74,6 +74,8 @@ export type SubBlockType =
| 'input-mapping' // Map parent variables to child workflow input schema
| 'variables-input' // Variable assignments for updating workflow variables
| 'messages-input' // Multiple message inputs with role and content for LLM message history
| 'workflow-selector' // Workflow selector for agent tools
| 'workflow-input-mapper' // Dynamic workflow input mapper based on selected workflow
| 'text' // Read-only text display
/**
@@ -87,6 +89,7 @@ export const SELECTOR_TYPES_HYDRATION_REQUIRED: SubBlockType[] = [
'folder-selector',
'project-selector',
'knowledge-base-selector',
'workflow-selector',
'document-selector',
'variables-input',
'mcp-server-selector',

View File

@@ -42,7 +42,8 @@ async function fetchWorkflows(workspaceId: string): Promise<WorkflowMetadata[]>
return data.map(mapWorkflow)
}
export function useWorkflows(workspaceId?: string) {
export function useWorkflows(workspaceId?: string, options?: { syncRegistry?: boolean }) {
const { syncRegistry = true } = options || {}
const beginMetadataLoad = useWorkflowRegistry((state) => state.beginMetadataLoad)
const completeMetadataLoad = useWorkflowRegistry((state) => state.completeMetadataLoad)
const failMetadataLoad = useWorkflowRegistry((state) => state.failMetadataLoad)
@@ -56,24 +57,24 @@ export function useWorkflows(workspaceId?: string) {
})
useEffect(() => {
if (workspaceId && query.status === 'pending') {
if (syncRegistry && workspaceId && query.status === 'pending') {
beginMetadataLoad(workspaceId)
}
}, [workspaceId, query.status, beginMetadataLoad])
}, [syncRegistry, workspaceId, query.status, beginMetadataLoad])
useEffect(() => {
if (workspaceId && query.status === 'success' && query.data) {
if (syncRegistry && workspaceId && query.status === 'success' && query.data) {
completeMetadataLoad(workspaceId, query.data)
}
}, [workspaceId, query.status, query.data, completeMetadataLoad])
}, [syncRegistry, workspaceId, query.status, query.data, completeMetadataLoad])
useEffect(() => {
if (workspaceId && query.status === 'error') {
if (syncRegistry && workspaceId && query.status === 'error') {
const message =
query.error instanceof Error ? query.error.message : 'Failed to fetch workflows'
failMetadataLoad(workspaceId, message)
}
}, [workspaceId, query.status, query.error, failMetadataLoad])
}, [syncRegistry, workspaceId, query.status, query.error, failMetadataLoad])
return query
}

View File

@@ -1,4 +1,5 @@
import { Sandbox } from '@e2b/code-interpreter'
import { env } from '@/lib/env'
import { CodeLanguage } from '@/lib/execution/languages'
import { createLogger } from '@/lib/logs/console/logger'
@@ -20,13 +21,7 @@ const logger = createLogger('E2BExecution')
export async function executeInE2B(req: E2BExecutionRequest): Promise<E2BExecutionResult> {
const { code, language, timeoutMs } = req
logger.info(`Executing code in E2B`, {
code,
language,
timeoutMs,
})
const apiKey = process.env.E2B_API_KEY
const apiKey = env.E2B_API_KEY
if (!apiKey) {
throw new Error('E2B_API_KEY is required when E2B is enabled')
}
@@ -42,7 +37,6 @@ export async function executeInE2B(req: E2BExecutionRequest): Promise<E2BExecuti
timeoutMs,
})
// Check for execution errors
if (execution.error) {
const errorMessage = `${execution.error.name}: ${execution.error.value}`
logger.error(`E2B execution error`, {
@@ -51,7 +45,6 @@ export async function executeInE2B(req: E2BExecutionRequest): Promise<E2BExecuti
errorMessage,
})
// Include error traceback in stdout if available
const errorOutput = execution.error.traceback || errorMessage
return {
result: null,
@@ -61,7 +54,6 @@ export async function executeInE2B(req: E2BExecutionRequest): Promise<E2BExecuti
}
}
// Get output from execution
if (execution.text) {
stdoutChunks.push(execution.text)
}

View File

@@ -491,7 +491,7 @@ export async function transformBlockTool(
const userProvidedParams = block.params || {}
// Create LLM schema that excludes user-provided parameters
const llmSchema = createLLMToolSchema(toolConfig, userProvidedParams)
const llmSchema = await createLLMToolSchema(toolConfig, userProvidedParams)
// Return formatted tool config
return {

View File

@@ -7,7 +7,7 @@ export const functionExecuteTool: ToolConfig<CodeExecutionInput, CodeExecutionOu
id: 'function_execute',
name: 'Function Execute',
description:
'Execute JavaScript code in a secure, sandboxed environment with proper isolation and resource limits.',
'Execute JavaScript code. fetch() is available. Code runs in async IIFE wrapper automatically. CRITICAL: Write plain statements with await/return, NOT wrapped in functions. Example for API call: const res = await fetch(url); const data = await res.json(); return data;',
version: '1.0.0',
params: {
@@ -15,7 +15,8 @@ export const functionExecuteTool: ToolConfig<CodeExecutionInput, CodeExecutionOu
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The code to execute',
description:
'Raw JavaScript statements (NOT a function). Code is auto-wrapped in async context. MUST use fetch() for HTTP (NOT xhr/axios/request libs). Write like: await fetch(url) then return result. NO import/require statements.',
},
language: {
type: 'string',
@@ -27,35 +28,35 @@ export const functionExecuteTool: ToolConfig<CodeExecutionInput, CodeExecutionOu
timeout: {
type: 'number',
required: false,
visibility: 'user-only',
visibility: 'hidden',
description: 'Execution timeout in milliseconds',
default: DEFAULT_EXECUTION_TIMEOUT_MS,
},
envVars: {
type: 'object',
required: false,
visibility: 'user-only',
visibility: 'hidden',
description: 'Environment variables to make available during execution',
default: {},
},
blockData: {
type: 'object',
required: false,
visibility: 'user-only',
visibility: 'hidden',
description: 'Block output data for variable resolution',
default: {},
},
blockNameMapping: {
type: 'object',
required: false,
visibility: 'user-only',
visibility: 'hidden',
description: 'Mapping of block names to block IDs',
default: {},
},
workflowVariables: {
type: 'object',
required: false,
visibility: 'user-only',
visibility: 'hidden',
description: 'Workflow variables for <variable.name> resolution',
default: {},
},

View File

@@ -11,57 +11,76 @@ export const knowledgeCreateDocumentTool: ToolConfig<any, KnowledgeCreateDocumen
knowledgeBaseId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the knowledge base containing the document',
},
name: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Name of the document',
},
content: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Content of the document',
},
tag1: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Tag 1 value for the document',
},
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' },
},
},
},
},

View File

@@ -11,22 +11,33 @@ export const knowledgeSearchTool: ToolConfig<any, KnowledgeSearchResponse> = {
knowledgeBaseId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the knowledge base to search in',
},
query: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Search query text (optional when using tag filters)',
},
topK: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of most similar results to return (1-100)',
},
tagFilters: {
type: 'array',
required: false,
visibility: 'user-or-llm',
description: 'Array of tag filters with tagName and tagValue properties',
items: {
type: 'object',
properties: {
tagName: { type: 'string' },
tagValue: { type: 'string' },
},
},
},
},

View File

@@ -11,16 +11,19 @@ export const knowledgeUploadChunkTool: ToolConfig<any, KnowledgeUploadChunkRespo
knowledgeBaseId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the knowledge base containing the document',
},
documentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the document to upload the chunk to',
},
content: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Content of the chunk to upload',
},
},

View File

@@ -82,13 +82,13 @@ describe('Tool Parameters Utils', () => {
})
describe('createLLMToolSchema', () => {
it.concurrent('should create schema excluding user-provided parameters', () => {
it.concurrent('should create schema excluding user-provided parameters', async () => {
const userProvidedParams = {
apiKey: 'user-provided-key',
channel: '#general',
}
const schema = createLLMToolSchema(mockToolConfig, userProvidedParams)
const schema = await createLLMToolSchema(mockToolConfig, userProvidedParams)
expect(schema.properties).not.toHaveProperty('apiKey') // user-only, excluded
expect(schema.properties).not.toHaveProperty('channel') // user-provided, excluded
@@ -98,8 +98,8 @@ describe('Tool Parameters Utils', () => {
expect(schema.required).not.toContain('apiKey') // user-only, never required for LLM
})
it.concurrent('should include all parameters when none are user-provided', () => {
const schema = createLLMToolSchema(mockToolConfig, {})
it.concurrent('should include all parameters when none are user-provided', async () => {
const schema = await createLLMToolSchema(mockToolConfig, {})
expect(schema.properties).not.toHaveProperty('apiKey') // user-only, never shown to LLM
expect(schema.properties).toHaveProperty('message') // user-or-llm, shown to LLM
@@ -226,8 +226,8 @@ describe('Tool Parameters Utils', () => {
})
describe('Type Interface Validation', () => {
it.concurrent('should have properly typed ToolSchema', () => {
const schema: ToolSchema = createLLMToolSchema(mockToolConfig, {})
it.concurrent('should have properly typed ToolSchema', async () => {
const schema: ToolSchema = await createLLMToolSchema(mockToolConfig, {})
expect(schema.type).toBe('object')
expect(typeof schema.properties).toBe('object')

View File

@@ -11,7 +11,8 @@ export interface Option {
export interface ComponentCondition {
field: string
value: string
value: string | number | boolean | Array<string | number | boolean>
not?: boolean
}
export interface UIComponentConfig {
@@ -35,6 +36,7 @@ export interface UIComponentConfig {
generationType?: string
acceptedTypes?: string[]
multiple?: boolean
multiSelect?: boolean
maxSize?: number
dependsOn?: string[]
}
@@ -108,6 +110,12 @@ 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 {
@@ -146,6 +154,51 @@ export function getToolParametersConfig(
return null
}
// Special handling for workflow_executor tool
if (toolId === 'workflow_executor') {
const parameters: ToolParameterConfig[] = [
{
id: 'workflowId',
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the workflow to execute',
uiComponent: {
type: 'workflow-selector',
placeholder: 'Select workflow to execute',
},
},
{
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
},
},
},
]
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: BlockConfig | null = null
if (blockType) {
@@ -276,10 +329,10 @@ export function getToolParametersConfig(
/**
* Creates a tool schema for LLM with user-provided parameters excluded
*/
export function createLLMToolSchema(
export async function createLLMToolSchema(
toolConfig: ToolConfig,
userProvidedParams: Record<string, unknown>
): ToolSchema {
): Promise<ToolSchema> {
const schema: ToolSchema = {
type: 'object',
properties: {},
@@ -287,7 +340,7 @@ export function createLLMToolSchema(
}
// Only include parameters that the LLM should/can provide
Object.entries(toolConfig.params).forEach(([paramId, param]) => {
for (const [paramId, param] of Object.entries(toolConfig.params)) {
const isUserProvided =
userProvidedParams[paramId] !== undefined &&
userProvidedParams[paramId] !== null &&
@@ -295,17 +348,17 @@ export function createLLMToolSchema(
// Skip parameters that user has already provided
if (isUserProvided) {
return
continue
}
// Skip parameters that are user-only (never shown to LLM)
if (param.visibility === 'user-only') {
return
continue
}
// Skip hidden parameters
if (param.visibility === 'hidden') {
return
continue
}
// Add parameter to LLM schema
@@ -314,20 +367,140 @@ export function createLLMToolSchema(
schemaType = 'object'
}
schema.properties[paramId] = {
const propertySchema: any = {
type: schemaType,
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}"`
)
}
// Special handling for workflow_executor's inputMapping parameter
if (toolConfig.id === 'workflow_executor' && paramId === 'inputMapping') {
const workflowId = userProvidedParams.workflowId as string
if (workflowId) {
await applyDynamicSchemaForWorkflow(propertySchema, workflowId)
}
}
schema.properties[paramId] = propertySchema
// Add to required if LLM must provide it and it's originally required
if ((param.visibility === 'user-or-llm' || param.visibility === 'llm-only') && param.required) {
schema.required.push(paramId)
}
})
}
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: `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)
}
}
/**
* Helper function to fetch workflow input fields with caching
*/
async function fetchWorkflowInputFields(
workflowId: string
): Promise<Array<{ name: string; type: string }>> {
// Check cache first
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')
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()
if (!data?.state?.blocks) {
return []
}
const blocks = data.state.blocks as Record<string, any>
const triggerEntry = Object.entries(blocks).find(
([, block]) =>
block.type === 'start_trigger' || block.type === 'input_trigger' || block.type === 'starter'
)
if (!triggerEntry) {
return []
}
const triggerBlock = triggerEntry[1]
const inputFormat = triggerBlock.subBlocks?.inputFormat?.value
let fields: Array<{ name: string; type: string }> = []
if (Array.isArray(inputFormat)) {
fields = inputFormat
.filter((field: any) => field.name && typeof field.name === 'string')
.map((field: any) => ({
name: field.name,
type: field.type || 'string',
}))
}
// Cache the result
workflowInputFieldsCache.set(workflowId, { fields, timestamp: now })
return fields
} catch (error) {
logger.error('Error fetching workflow input fields:', error)
return []
}
}
/**
* Creates a complete tool schema for execution with all parameters
*/
@@ -339,11 +512,27 @@ export function createExecutionToolSchema(toolConfig: ToolConfig): ToolSchema {
}
Object.entries(toolConfig.params).forEach(([paramId, param]) => {
schema.properties[paramId] = {
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)
}

View File

@@ -52,6 +52,11 @@ export interface ToolConfig<P = any, R = any> {
visibility?: ParameterVisibility
default?: any
description?: string
items?: {
type: string
description?: string
properties?: Record<string, { type: string; description?: string }>
}
}
>

View File

@@ -1,9 +1,6 @@
import { createLogger } from '@/lib/logs/console/logger'
import type { ToolConfig } from '@/tools/types'
import type { WorkflowExecutorParams, WorkflowExecutorResponse } from '@/tools/workflow/types'
const logger = createLogger('WorkflowExecutorTool')
/**
* Tool for executing workflows as blocks within other workflows.
* This tool is used by the WorkflowBlockHandler to provide the execution capability.
@@ -14,38 +11,46 @@ export const workflowExecutorTool: ToolConfig<
> = {
id: 'workflow_executor',
name: 'Workflow Executor',
description: 'Execute another workflow inline as a block',
description:
'Execute another workflow as a sub-workflow. Pass inputs as a JSON object with field names matching the child workflow\'s input format. Example: if child expects "name" and "email", pass {"name": "John", "email": "john@example.com"}',
version: '1.0.0',
params: {
workflowId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the workflow to execute',
},
inputMapping: {
type: 'object',
required: false,
description: 'JSON object mapping parent data to child workflow inputs',
visibility: 'user-or-llm',
description:
'JSON object with keys matching the child workflow\'s input field names. Each key should map to the value you want to pass for that input field. Example: {"fieldName": "value", "otherField": 123}',
},
},
request: {
url: '/api/tools/workflow-executor',
url: (params: WorkflowExecutorParams) => `/api/workflows/${params.workflowId}/execute`,
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => params,
body: (params: WorkflowExecutorParams) => ({
input: params.inputMapping || {},
triggerType: 'api',
useDraftState: false,
}),
},
transformResponse: async (response: any) => {
logger.info('Workflow executor tool response received', { response })
// Extract success state from response, default to false if not present
const success = response?.success ?? false
transformResponse: async (response: Response) => {
const data = await response.json()
const outputData = data?.output ?? {}
return {
success,
duration: response?.duration ?? 0,
childWorkflowId: response?.childWorkflowId ?? '',
childWorkflowName: response?.childWorkflowName ?? '',
...response,
success: data?.success ?? false,
duration: data?.metadata?.duration ?? 0,
childWorkflowId: data?.workflowId ?? '',
childWorkflowName: data?.workflowName ?? '',
output: outputData, // For OpenAI provider
result: outputData, // For backwards compatibility
error: data?.error,
}
},
}