mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(custom-tools, copilot): custom tools state + copilot fixes (#2264)
* Workspace env vars * Fix execution animation on copilot run * Custom tools toolg * Custom tools * Fix custom tool * remove extra fallback * Fix lint
This commit is contained in:
committed by
GitHub
parent
b7a1e8f5cf
commit
6b4d76298f
@@ -127,7 +127,8 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
})
|
||||
.map(async (tool) => {
|
||||
try {
|
||||
if (tool.type === 'custom-tool' && tool.schema) {
|
||||
// Handle custom tools - either inline (schema) or reference (customToolId)
|
||||
if (tool.type === 'custom-tool' && (tool.schema || tool.customToolId)) {
|
||||
return await this.createCustomTool(ctx, tool)
|
||||
}
|
||||
if (tool.type === 'mcp') {
|
||||
@@ -151,24 +152,47 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
private async createCustomTool(ctx: ExecutionContext, tool: ToolInput): Promise<any> {
|
||||
const userProvidedParams = tool.params || {}
|
||||
|
||||
// Resolve tool definition - either inline or from database reference
|
||||
let schema = tool.schema
|
||||
let code = tool.code
|
||||
let title = tool.title
|
||||
|
||||
// If this is a reference-only tool (has customToolId but no schema), fetch from API
|
||||
if (tool.customToolId && !schema) {
|
||||
const resolved = await this.fetchCustomToolById(ctx, tool.customToolId)
|
||||
if (!resolved) {
|
||||
logger.error(`Custom tool not found: ${tool.customToolId}`)
|
||||
return null
|
||||
}
|
||||
schema = resolved.schema
|
||||
code = resolved.code
|
||||
title = resolved.title
|
||||
}
|
||||
|
||||
// Validate we have the required data
|
||||
if (!schema?.function) {
|
||||
logger.error('Custom tool missing schema:', { customToolId: tool.customToolId, title })
|
||||
return null
|
||||
}
|
||||
|
||||
const { filterSchemaForLLM, mergeToolParameters } = await import('@/tools/params')
|
||||
|
||||
const filteredSchema = filterSchemaForLLM(tool.schema.function.parameters, userProvidedParams)
|
||||
const filteredSchema = filterSchemaForLLM(schema.function.parameters, userProvidedParams)
|
||||
|
||||
const toolId = `${AGENT.CUSTOM_TOOL_PREFIX}${tool.title}`
|
||||
const toolId = `${AGENT.CUSTOM_TOOL_PREFIX}${title}`
|
||||
const base: any = {
|
||||
id: toolId,
|
||||
name: tool.schema.function.name,
|
||||
description: tool.schema.function.description || '',
|
||||
name: schema.function.name,
|
||||
description: schema.function.description || '',
|
||||
params: userProvidedParams,
|
||||
parameters: {
|
||||
...filteredSchema,
|
||||
type: tool.schema.function.parameters.type,
|
||||
type: schema.function.parameters.type,
|
||||
},
|
||||
usageControl: tool.usageControl || 'auto',
|
||||
}
|
||||
|
||||
if (tool.code) {
|
||||
if (code) {
|
||||
base.executeFunction = async (callParams: Record<string, any>) => {
|
||||
const mergedParams = mergeToolParameters(userProvidedParams, callParams)
|
||||
|
||||
@@ -177,7 +201,7 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
const result = await executeTool(
|
||||
'function_execute',
|
||||
{
|
||||
code: tool.code,
|
||||
code,
|
||||
...mergedParams,
|
||||
timeout: tool.timeout ?? AGENT.DEFAULT_FUNCTION_TIMEOUT,
|
||||
envVars: ctx.environmentVariables || {},
|
||||
@@ -205,6 +229,78 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
return base
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a custom tool definition from the database by ID
|
||||
* Uses Zustand store in browser, API call on server
|
||||
*/
|
||||
private async fetchCustomToolById(
|
||||
ctx: ExecutionContext,
|
||||
customToolId: string
|
||||
): Promise<{ schema: any; code: string; title: string } | null> {
|
||||
// In browser, use the Zustand store which has cached data from React Query
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const { useCustomToolsStore } = await import('@/stores/custom-tools/store')
|
||||
const tool = useCustomToolsStore.getState().getTool(customToolId)
|
||||
if (tool) {
|
||||
return {
|
||||
schema: tool.schema,
|
||||
code: tool.code || '',
|
||||
title: tool.title,
|
||||
}
|
||||
}
|
||||
logger.warn(`Custom tool not found in store: ${customToolId}`)
|
||||
} catch (error) {
|
||||
logger.error('Error accessing custom tools store:', { error })
|
||||
}
|
||||
}
|
||||
|
||||
// Server-side: fetch from API
|
||||
try {
|
||||
const headers = await buildAuthHeaders()
|
||||
const params: Record<string, string> = {}
|
||||
|
||||
if (ctx.workspaceId) {
|
||||
params.workspaceId = ctx.workspaceId
|
||||
}
|
||||
if (ctx.workflowId) {
|
||||
params.workflowId = ctx.workflowId
|
||||
}
|
||||
|
||||
const url = buildAPIUrl('/api/tools/custom', params)
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(`Failed to fetch custom tools: ${response.status}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (!data.data || !Array.isArray(data.data)) {
|
||||
logger.error('Invalid custom tools API response')
|
||||
return null
|
||||
}
|
||||
|
||||
const tool = data.data.find((t: any) => t.id === customToolId)
|
||||
if (!tool) {
|
||||
logger.warn(`Custom tool not found by ID: ${customToolId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
schema: tool.schema,
|
||||
code: tool.code || '',
|
||||
title: tool.title,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching custom tool:', { customToolId, error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async createMcpTool(ctx: ExecutionContext, tool: ToolInput): Promise<any> {
|
||||
const { serverId, toolName, ...userProvidedParams } = tool.params || {}
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface ToolInput {
|
||||
timeout?: number
|
||||
usageControl?: 'auto' | 'force' | 'none'
|
||||
operation?: string
|
||||
/** Database ID for custom tools (new reference format) */
|
||||
customToolId?: string
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
|
||||
@@ -245,9 +245,11 @@ export class LoopOrchestrator {
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates the initial condition for while loops at the sentinel start.
|
||||
* For while loops, the condition must be checked BEFORE the first iteration.
|
||||
* If the condition is false, the loop body should be skipped entirely.
|
||||
* Evaluates the initial condition for loops at the sentinel start.
|
||||
* - For while loops, the condition must be checked BEFORE the first iteration.
|
||||
* - For forEach loops, skip if the items array is empty.
|
||||
* - For for loops, skip if maxIterations is 0.
|
||||
* - For doWhile loops, always execute at least once.
|
||||
*
|
||||
* @returns true if the loop should execute, false if it should be skipped
|
||||
*/
|
||||
@@ -258,27 +260,47 @@ export class LoopOrchestrator {
|
||||
return true
|
||||
}
|
||||
|
||||
// Only while loops need an initial condition check
|
||||
// - for/forEach: always execute based on iteration count/items
|
||||
// - doWhile: always execute at least once, check condition after
|
||||
// - while: check condition before first iteration
|
||||
if (scope.loopType !== 'while') {
|
||||
// forEach: skip if items array is empty
|
||||
if (scope.loopType === 'forEach') {
|
||||
if (!scope.items || scope.items.length === 0) {
|
||||
logger.info('ForEach loop has empty items, skipping loop body', { loopId })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (!scope.condition) {
|
||||
logger.warn('No condition defined for while loop', { loopId })
|
||||
return false
|
||||
// for: skip if maxIterations is 0
|
||||
if (scope.loopType === 'for') {
|
||||
if (scope.maxIterations === 0) {
|
||||
logger.info('For loop has 0 iterations, skipping loop body', { loopId })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const result = this.evaluateWhileCondition(ctx, scope.condition, scope)
|
||||
logger.info('While loop initial condition evaluation', {
|
||||
loopId,
|
||||
condition: scope.condition,
|
||||
result,
|
||||
})
|
||||
// doWhile: always execute at least once
|
||||
if (scope.loopType === 'doWhile') {
|
||||
return true
|
||||
}
|
||||
|
||||
return result
|
||||
// while: check condition before first iteration
|
||||
if (scope.loopType === 'while') {
|
||||
if (!scope.condition) {
|
||||
logger.warn('No condition defined for while loop', { loopId })
|
||||
return false
|
||||
}
|
||||
|
||||
const result = this.evaluateWhileCondition(ctx, scope.condition, scope)
|
||||
logger.info('While loop initial condition evaluation', {
|
||||
loopId,
|
||||
condition: scope.condition,
|
||||
result,
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
shouldExecuteLoopNode(_ctx: ExecutionContext, _nodeId: string, _loopId: string): boolean {
|
||||
|
||||
@@ -38,17 +38,42 @@ export function extractLoopIdFromSentinel(sentinelId: string): string | null {
|
||||
|
||||
/**
|
||||
* Parse distribution items from parallel config
|
||||
* Handles: arrays, JSON strings, and references
|
||||
* Handles: arrays, JSON strings, objects, and references
|
||||
* Note: References (starting with '<') cannot be resolved at DAG construction time,
|
||||
* they must be resolved at runtime. This function returns [] for references.
|
||||
*/
|
||||
export function parseDistributionItems(config: SerializedParallel): any[] {
|
||||
const rawItems = config.distribution ?? []
|
||||
if (typeof rawItems === 'string' && rawItems.startsWith(REFERENCE.START)) {
|
||||
return []
|
||||
|
||||
// Already an array - return as-is
|
||||
if (Array.isArray(rawItems)) {
|
||||
return rawItems
|
||||
}
|
||||
|
||||
// Object - convert to entries array (consistent with loop forEach behavior)
|
||||
if (typeof rawItems === 'object' && rawItems !== null) {
|
||||
return Object.entries(rawItems)
|
||||
}
|
||||
|
||||
// String handling
|
||||
if (typeof rawItems === 'string') {
|
||||
// References cannot be resolved at DAG construction time
|
||||
if (rawItems.startsWith(REFERENCE.START) && rawItems.endsWith(REFERENCE.END)) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Try to parse as JSON
|
||||
try {
|
||||
const normalizedJSON = rawItems.replace(/'/g, '"')
|
||||
return JSON.parse(normalizedJSON)
|
||||
const parsed = JSON.parse(normalizedJSON)
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
// Parsed to non-array (e.g. object) - convert to entries
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
return Object.entries(parsed)
|
||||
}
|
||||
return []
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse distribution items', {
|
||||
rawItems,
|
||||
@@ -57,12 +82,7 @@ export function parseDistributionItems(config: SerializedParallel): any[] {
|
||||
return []
|
||||
}
|
||||
}
|
||||
if (Array.isArray(rawItems)) {
|
||||
return rawItems
|
||||
}
|
||||
if (typeof rawItems === 'object' && rawItems !== null) {
|
||||
return [rawItems]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -98,16 +98,43 @@ export class ParallelResolver implements Resolver {
|
||||
return undefined
|
||||
}
|
||||
|
||||
private getDistributionItems(parallelConfig: any): any {
|
||||
let distributionItems = parallelConfig.distributionItems || parallelConfig.distribution || []
|
||||
if (typeof distributionItems === 'string' && !distributionItems.startsWith('<')) {
|
||||
private getDistributionItems(parallelConfig: any): any[] {
|
||||
const rawItems = parallelConfig.distributionItems || parallelConfig.distribution || []
|
||||
|
||||
// Already an array - return as-is
|
||||
if (Array.isArray(rawItems)) {
|
||||
return rawItems
|
||||
}
|
||||
|
||||
// Object - convert to entries array (consistent with loop forEach behavior)
|
||||
if (typeof rawItems === 'object' && rawItems !== null) {
|
||||
return Object.entries(rawItems)
|
||||
}
|
||||
|
||||
// String handling
|
||||
if (typeof rawItems === 'string') {
|
||||
// Skip references - they should be resolved by the variable resolver
|
||||
if (rawItems.startsWith('<')) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Try to parse as JSON
|
||||
try {
|
||||
distributionItems = JSON.parse(distributionItems.replace(/'/g, '"'))
|
||||
const parsed = JSON.parse(rawItems.replace(/'/g, '"'))
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
// Parsed to non-array (e.g. object) - convert to entries
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
return Object.entries(parsed)
|
||||
}
|
||||
return []
|
||||
} catch (e) {
|
||||
logger.error('Failed to parse distribution items', { distributionItems })
|
||||
logger.error('Failed to parse distribution items', { rawItems })
|
||||
return []
|
||||
}
|
||||
}
|
||||
return distributionItems
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user