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:
Siddharth Ganesan
2025-12-08 20:14:49 -08:00
committed by GitHub
parent b7a1e8f5cf
commit 6b4d76298f
25 changed files with 1456 additions and 554 deletions

View File

@@ -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 || {}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 []
}
/**

View File

@@ -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 []
}
}