diff --git a/apps/sim/app/api/admin/mothership/route.ts b/apps/sim/app/api/admin/mothership/route.ts index 3d3d722151..c298370ed3 100644 --- a/apps/sim/app/api/admin/mothership/route.ts +++ b/apps/sim/app/api/admin/mothership/route.ts @@ -1,3 +1,6 @@ +import { db } from '@sim/db' +import { user } from '@sim/db/schema' +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' @@ -12,6 +15,19 @@ function getMothershipUrl(environment: string): string | null { return ENV_URLS[environment] ?? null } +async function isAdminRequestAuthorized() { + const session = await getSession() + if (!session?.user?.id) return false + + const [currentUser] = await db + .select({ role: user.role }) + .from(user) + .where(eq(user.id, session.user.id)) + .limit(1) + + return currentUser?.role === 'admin' +} + /** * Proxy to the mothership admin API. * @@ -23,8 +39,7 @@ function getMothershipUrl(environment: string): string | null { * (e.g. requestId for GET /traces) are forwarded. */ export async function POST(req: NextRequest) { - const session = await getSession() - if (!session?.user || session.user.role !== 'admin') { + if (!(await isAdminRequestAuthorized())) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -75,8 +90,7 @@ export async function POST(req: NextRequest) { } export async function GET(req: NextRequest) { - const session = await getSession() - if (!session?.user || session.user.role !== 'admin') { + if (!(await isAdminRequestAuthorized())) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/apps/sim/app/api/mcp/copilot/route.ts b/apps/sim/app/api/mcp/copilot/route.ts index fca77e30cb..8b8eff82ec 100644 --- a/apps/sim/app/api/mcp/copilot/route.ts +++ b/apps/sim/app/api/mcp/copilot/route.ts @@ -143,8 +143,8 @@ When the user refers to a workflow by name or description ("the email one", "my ### Key Rules - You can test workflows immediately after building — deployment is only needed for external access (API, chat, MCP). -- All copilot tools (build, plan, edit, deploy, test, debug) require workflowId. -- If the user reports errors → use \`sim_debug\` first, don't guess. +- All workflow-scoped copilot tools require \`workflowId\`. +- If the user reports errors, route through \`sim_workflow\` and ask it to reproduce, inspect logs, and fix the issue end to end. - Variable syntax: \`\` for block outputs, \`{{ENV_VAR}}\` for env vars. ` diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index c799930ea9..12a491b7d2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -2,7 +2,6 @@ import type { ComponentType, SVGProps } from 'react' import { Asterisk, Blimp, - Bug, Calendar, Database, Eye, @@ -55,7 +54,6 @@ const TOOL_ICONS: Record = { agent: AgentIcon, custom_tool: Wrench, research: Search, - debug: Bug, context_compaction: Asterisk, open_resource: Eye, file: File, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 8ac22e6c34..7749bcb353 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -62,6 +62,7 @@ import { isResourceToolName, } from '@/lib/copilot/resources/extraction' import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types' +import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { cancelRunToolExecution, executeRunToolOnClient, @@ -1575,7 +1576,7 @@ export function useChat( ? payload.name : 'unknown' const isPartial = payload.partial === true - if (name === ToolSearchToolRegex.id) { + if (name === ToolSearchToolRegex.id || isToolHiddenInUi(name)) { break } const ui = getToolUI(payload) diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 80d3ec5ad5..b163037cd1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -2,7 +2,6 @@ import { Agent, Auth, CreateWorkflow, - Debug, Deploy, EditContent, EditWorkflow, @@ -188,7 +187,6 @@ export const SUBAGENT_LABELS: Record = { table: 'Table Agent', custom_tool: 'Custom Tool Agent', superagent: 'Superagent', - debug: 'Debug Agent', run: 'Run Agent', agent: 'Tools Agent', job: 'Job Agent', @@ -315,7 +313,6 @@ export const TOOL_UI_METADATA: Record = { phase: 'subagent', }, [Research.id]: { title: 'Research Agent', phaseLabel: 'Research', phase: 'subagent' }, - [Debug.id]: { title: 'Debug Agent', phaseLabel: 'Debug', phase: 'subagent' }, [OpenResource.id]: { title: 'Opening resource', phaseLabel: 'Resource', diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx index aa96075b40..fac175177f 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx @@ -315,7 +315,7 @@ function OverviewTab({ {r.error ? ( Error ) : r.aborted ? ( - Abort + Abort ) : ( OK )} @@ -694,7 +694,7 @@ function TraceDetail({ trace }: { trace: TraceData }) { trace.outcome === 'success' ? 'green' : trace.outcome === 'cancelled' - ? 'yellow' + ? 'amber' : 'red' } > diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx index 620d38c2e8..df16b87b5b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx @@ -14,12 +14,11 @@ import { } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { - getEffectiveBlockOutputPaths, getEffectiveBlockOutputType, getOutputPathsFromSchema, } from '@/lib/workflows/blocks/block-outputs' +import { getBlockReferenceTags } from '@/lib/workflows/blocks/block-reference-tags' import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' -import { TRIGGER_TYPES } from '@/lib/workflows/triggers/triggers' import { KeyboardNavigationHandler } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/components/keyboard-navigation-handler' import type { BlockTagGroup, @@ -177,17 +176,6 @@ const ensureRootTag = (tags: string[], rootTag: string): string[] => { return [rootTag, ...tags] } -/** - * Gets a subblock value from the store. - * - * @param blockId - The block identifier - * @param property - The property name to retrieve - * @returns The value from the subblock store - */ -const getSubBlockValue = (blockId: string, property: string): any => { - return useSubBlockStore.getState().getValue(blockId, property) -} - /** * Gets the output type for a specific path in a block's outputs. * @@ -1055,53 +1043,19 @@ export const TagDropdown: React.FC = ({ return { tags: [], variableInfoMap: emptyVariableInfoMap, blockTagGroups: [] } } - const blockName = sourceBlock.name || sourceBlock.type - const normalizedBlockName = normalizeName(blockName) - const mergedSubBlocks = getMergedSubBlocks(activeSourceBlockId) - let blockTags: string[] - - if (sourceBlock.type === 'variables') { - const variablesValue = getSubBlockValue(activeSourceBlockId, 'variables') - - if (variablesValue && Array.isArray(variablesValue) && variablesValue.length > 0) { - const validAssignments = variablesValue.filter((assignment: { variableName?: string }) => - assignment?.variableName?.trim() - ) - blockTags = validAssignments.map( - (assignment: { variableName: string }) => - `${normalizedBlockName}.${assignment.variableName.trim()}` - ) - } else { - blockTags = [normalizedBlockName] - } - } else { - const sourceBlockConfig = getBlock(sourceBlock.type) - const isTriggerCapable = sourceBlockConfig ? hasTriggerCapability(sourceBlockConfig) : false - const effectiveTriggerMode = Boolean(sourceBlock.triggerMode && isTriggerCapable) - const outputPaths = getEffectiveBlockOutputPaths(sourceBlock.type, mergedSubBlocks, { - triggerMode: effectiveTriggerMode, - preferToolOutputs: !effectiveTriggerMode, - }) - const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`) - - if (sourceBlock.type === 'human_in_the_loop' && activeSourceBlockId === blockId) { - blockTags = allTags.filter( - (tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint') - ) - } else if (allTags.length === 0) { - blockTags = [normalizedBlockName] - } else { - blockTags = allTags - } - } - - blockTags = ensureRootTag(blockTags, normalizedBlockName) - const shouldShowRootTag = - sourceBlock.type === TRIGGER_TYPES.GENERIC_WEBHOOK || sourceBlock.type === 'start_trigger' - if (!shouldShowRootTag) { - blockTags = blockTags.filter((tag) => tag !== normalizedBlockName) - } + const blockName = sourceBlock.name || sourceBlock.type + const blockTags = getBlockReferenceTags({ + block: { + id: activeSourceBlockId, + type: sourceBlock.type, + name: sourceBlock.name, + triggerMode: sourceBlock.triggerMode, + subBlocks: mergedSubBlocks, + }, + currentBlockId: blockId, + subBlocks: mergedSubBlocks, + }) const blockTagGroups: BlockTagGroup[] = [ { @@ -1331,57 +1285,19 @@ export const TagDropdown: React.FC = ({ continue } - const blockName = accessibleBlock.name || accessibleBlock.type - const normalizedBlockName = normalizeName(blockName) - const mergedSubBlocks = getMergedSubBlocks(accessibleBlockId) - - let blockTags: string[] - - if (accessibleBlock.type === 'variables') { - const variablesValue = getSubBlockValue(accessibleBlockId, 'variables') - - if (variablesValue && Array.isArray(variablesValue) && variablesValue.length > 0) { - const validAssignments = variablesValue.filter((assignment: { variableName?: string }) => - assignment?.variableName?.trim() - ) - blockTags = validAssignments.map( - (assignment: { variableName: string }) => - `${normalizedBlockName}.${assignment.variableName.trim()}` - ) - } else { - blockTags = [normalizedBlockName] - } - } else { - const accessibleBlockConfig = getBlock(accessibleBlock.type) - const isTriggerCapable = accessibleBlockConfig - ? hasTriggerCapability(accessibleBlockConfig) - : false - const effectiveTriggerMode = Boolean(accessibleBlock.triggerMode && isTriggerCapable) - const outputPaths = getEffectiveBlockOutputPaths(accessibleBlock.type, mergedSubBlocks, { - triggerMode: effectiveTriggerMode, - preferToolOutputs: !effectiveTriggerMode, - }) - const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`) - - if (accessibleBlock.type === 'human_in_the_loop' && accessibleBlockId === blockId) { - blockTags = allTags.filter( - (tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint') - ) - } else if (allTags.length === 0) { - blockTags = [normalizedBlockName] - } else { - blockTags = allTags - } - } - - blockTags = ensureRootTag(blockTags, normalizedBlockName) - const shouldShowRootTag = - accessibleBlock.type === TRIGGER_TYPES.GENERIC_WEBHOOK || - accessibleBlock.type === 'start_trigger' - if (!shouldShowRootTag) { - blockTags = blockTags.filter((tag) => tag !== normalizedBlockName) - } + const blockName = accessibleBlock.name || accessibleBlock.type + const blockTags = getBlockReferenceTags({ + block: { + id: accessibleBlockId, + type: accessibleBlock.type, + name: accessibleBlock.name, + triggerMode: accessibleBlock.triggerMode, + subBlocks: mergedSubBlocks, + }, + currentBlockId: blockId, + subBlocks: mergedSubBlocks, + }) blockTagGroups.push({ blockName, diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index d02eecc012..00caff1d9e 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -177,6 +177,7 @@ export interface ExecutionContext { userId?: string isDeployedContext?: boolean enforceCredentialAccess?: boolean + copilotToolExecution?: boolean permissionConfig?: PermissionGroupConfig | null permissionConfigLoaded?: boolean diff --git a/apps/sim/lib/copilot/chat/display-message.test.ts b/apps/sim/lib/copilot/chat/display-message.test.ts index 90648027a4..c389cd6fad 100644 --- a/apps/sim/lib/copilot/chat/display-message.test.ts +++ b/apps/sim/lib/copilot/chat/display-message.test.ts @@ -60,4 +60,32 @@ describe('display-message', () => { }, ]) }) + + it('hides load_agent_skill blocks from display output', () => { + const display = toDisplayMessage({ + id: 'msg-2', + role: 'assistant', + content: '', + timestamp: '2024-01-01T00:00:00.000Z', + contentBlocks: [ + { + type: 'tool', + phase: 'call', + toolCall: { + id: 'tool-hidden', + name: 'load_agent_skill', + state: 'success', + display: { title: 'Loading skill' }, + }, + }, + { + type: 'text', + channel: 'assistant', + content: 'visible text', + }, + ], + }) + + expect(display.contentBlocks).toEqual([{ type: 'text', content: 'visible text' }]) + }) }) diff --git a/apps/sim/lib/copilot/chat/display-message.ts b/apps/sim/lib/copilot/chat/display-message.ts index 913df9e30a..0d44097eb3 100644 --- a/apps/sim/lib/copilot/chat/display-message.ts +++ b/apps/sim/lib/copilot/chat/display-message.ts @@ -4,6 +4,7 @@ import { MothershipStreamV1SpanLifecycleEvent, MothershipStreamV1ToolOutcome, } from '@/lib/copilot/generated/mothership-stream-v1' +import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { type ChatContextKind, type ChatMessage, @@ -29,6 +30,7 @@ const STATE_TO_STATUS: Record = { function toToolCallInfo(block: PersistedContentBlock): ToolCallInfo | undefined { const tc = block.toolCall if (!tc) return undefined + if (isToolHiddenInUi(tc.name)) return undefined const status: ToolCallStatus = STATE_TO_STATUS[tc.state] ?? ToolCallStatus.error return { id: tc.id, @@ -42,7 +44,7 @@ function toToolCallInfo(block: PersistedContentBlock): ToolCallInfo | undefined } } -function toDisplayBlock(block: PersistedContentBlock): ContentBlock { +function toDisplayBlock(block: PersistedContentBlock): ContentBlock | undefined { switch (block.type) { case MothershipStreamV1EventType.text: if (block.lane === 'subagent') { @@ -53,6 +55,7 @@ function toDisplayBlock(block: PersistedContentBlock): ContentBlock { } return { type: ContentBlockType.text, content: block.content } case MothershipStreamV1EventType.tool: + if (!toToolCallInfo(block)) return undefined return { type: ContentBlockType.tool_call, toolCall: toToolCallInfo(block) } case MothershipStreamV1EventType.span: if (block.lifecycle === MothershipStreamV1SpanLifecycleEvent.end) { @@ -110,7 +113,9 @@ export function toDisplayMessage(msg: PersistedMessage): ChatMessage { } if (msg.contentBlocks && msg.contentBlocks.length > 0) { - display.contentBlocks = msg.contentBlocks.map(toDisplayBlock) + display.contentBlocks = msg.contentBlocks + .map(toDisplayBlock) + .filter((block): block is ContentBlock => !!block) } const attachments = toDisplayAttachment(msg.fileAttachments) diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts index 6da8503165..30bac9c084 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -3,7 +3,8 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetHighestPrioritySubscription } = vi.hoisted(() => ({ +const { mockCreateUserToolSchema, mockGetHighestPrioritySubscription } = vi.hoisted(() => ({ + mockCreateUserToolSchema: vi.fn(() => ({ type: 'object', properties: {} })), mockGetHighestPrioritySubscription: vi.fn(), })) @@ -56,7 +57,7 @@ vi.mock('@/tools/utils', () => ({ })) vi.mock('@/tools/params', () => ({ - createUserToolSchema: vi.fn(() => ({ type: 'object', properties: {} })), + createUserToolSchema: mockCreateUserToolSchema, })) import { buildIntegrationToolSchemas } from './payload' @@ -64,6 +65,7 @@ import { buildIntegrationToolSchemas } from './payload' describe('buildIntegrationToolSchemas', () => { beforeEach(() => { vi.clearAllMocks() + mockCreateUserToolSchema.mockReturnValue({ type: 'object', properties: {} }) }) it('appends the email footer prompt for free users', async () => { @@ -108,4 +110,19 @@ describe('buildIntegrationToolSchemas', () => { expect(gmailTool?.executeLocally).toBe(false) expect(runTool?.executeLocally).toBe(true) }) + + it('uses copilot-facing file schemas for integration tools', async () => { + mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro', status: 'active' }) + + await buildIntegrationToolSchemas('user-copilot') + + expect(mockCreateUserToolSchema).toHaveBeenCalledWith( + expect.objectContaining({ id: 'gmail_send' }), + { surface: 'copilot' } + ) + expect(mockCreateUserToolSchema).toHaveBeenCalledWith( + expect.objectContaining({ id: 'brandfetch_search' }), + { surface: 'copilot' } + ) + }) }) diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index 7e8e295070..f17533a5c5 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -42,6 +42,10 @@ export interface ToolSchema { oauth?: { required: boolean; provider: string } } +interface BuildIntegrationToolSchemasOptions { + schemaSurface?: 'default' | 'copilot' +} + /** * Build deferred integration tool schemas from the Sim tool registry. * Shared by the interactive chat payload builder and the non-interactive @@ -49,7 +53,8 @@ export interface ToolSchema { */ export async function buildIntegrationToolSchemas( userId: string, - messageId?: string + messageId?: string, + options: BuildIntegrationToolSchemasOptions = { schemaSurface: 'copilot' } ): Promise { const reqLogger = logger.withMetadata({ messageId }) const integrationTools: ToolSchema[] = [] @@ -70,7 +75,9 @@ export async function buildIntegrationToolSchemas( for (const [toolId, toolConfig] of Object.entries(latestTools)) { try { - const userSchema = createUserToolSchema(toolConfig) + const userSchema = createUserToolSchema(toolConfig, { + surface: options.schemaSurface ?? 'copilot', + }) const strippedName = stripVersionSuffix(toolId) const catalogEntry = getToolEntry(strippedName) integrationTools.push({ @@ -192,7 +199,9 @@ export async function buildCopilotRequestPayload( const payloadLogger = logger.withMetadata({ messageId: userMessageId }) if (effectiveMode === 'build') { - integrationTools = await buildIntegrationToolSchemas(userId, userMessageId) + integrationTools = await buildIntegrationToolSchemas(userId, userMessageId, { + schemaSurface: 'copilot', + }) // Discover MCP tools from workspace servers and include as deferred tools if (workflowId) { diff --git a/apps/sim/lib/copilot/generated/request-trace-v1.ts b/apps/sim/lib/copilot/generated/request-trace-v1.ts index 309b49ad06..f8d2bd06e0 100644 --- a/apps/sim/lib/copilot/generated/request-trace-v1.ts +++ b/apps/sim/lib/copilot/generated/request-trace-v1.ts @@ -15,7 +15,7 @@ export type RequestTraceV1SpanSource = 'sim' | 'go' * This interface was referenced by `RequestTraceV1SimReport`'s JSON-Schema * via the `definition` "RequestTraceV1SpanStatus". */ -export type RequestTraceV1SpanStatus = 'ok' | 'error' | 'cancelled' +export type RequestTraceV1SpanStatus = 'ok' | 'error' | 'cancelled' | 'pending' /** * Trace report sent from Sim to Go after a request completes. @@ -49,8 +49,8 @@ export interface RequestTraceV1CostSummary { */ export interface RequestTraceV1Span { attributes?: MothershipStreamV1AdditionalPropertiesMap - durationMs?: number - endMs?: number + durationMs: number + endMs: number kind?: string name: string parentName?: string @@ -129,4 +129,5 @@ export const RequestTraceV1SpanStatus = { ok: 'ok', error: 'error', cancelled: 'cancelled', + pending: 'pending', } as const diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index fac458b931..8af55774c3 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -18,7 +18,6 @@ export interface ToolCatalogEntry { | 'create_job' | 'create_workflow' | 'create_workspace_mcp_server' - | 'debug' | 'delete_file' | 'delete_folder' | 'delete_workflow' @@ -82,6 +81,7 @@ export interface ToolCatalogEntry { | 'search_library_docs' | 'search_online' | 'search_patterns' + | 'set_block_enabled' | 'set_environment_variables' | 'set_global_workflow_variables' | 'superagent' @@ -107,7 +107,6 @@ export interface ToolCatalogEntry { | 'create_job' | 'create_workflow' | 'create_workspace_mcp_server' - | 'debug' | 'delete_file' | 'delete_folder' | 'delete_workflow' @@ -171,6 +170,7 @@ export interface ToolCatalogEntry { | 'search_library_docs' | 'search_online' | 'search_patterns' + | 'set_block_enabled' | 'set_environment_variables' | 'set_global_workflow_variables' | 'superagent' @@ -189,7 +189,6 @@ export interface ToolCatalogEntry { subagentId?: | 'agent' | 'auth' - | 'debug' | 'deploy' | 'file' | 'job' @@ -448,31 +447,6 @@ export const CreateWorkspaceMcpServer: ToolCatalogEntry = { requiredPermission: 'admin', } -export const Debug: ToolCatalogEntry = { - id: 'debug', - name: 'debug', - executor: 'subagent', - mode: 'async', - parameters: { - properties: { - context: { - description: - 'Pre-gathered context: workflow state JSON, block schemas, error logs. The debug agent will skip re-reading anything included here.', - type: 'string', - }, - request: { - description: - 'What to debug. Include error messages, block IDs, and any context about the failure.', - type: 'string', - }, - }, - required: ['request'], - type: 'object', - }, - subagentId: 'debug', - internal: true, -} - export const DeleteFile: ToolCatalogEntry = { id: 'delete_file', name: 'delete_file', @@ -2309,6 +2283,33 @@ export const SearchPatterns: ToolCatalogEntry = { }, } +export const SetBlockEnabled: ToolCatalogEntry = { + id: 'set_block_enabled', + name: 'set_block_enabled', + executor: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + blockId: { + type: 'string', + description: 'The block ID whose enabled state should be changed.', + }, + enabled: { + type: 'boolean', + description: 'Set to true to enable the block, or false to disable it.', + }, + workflowId: { + type: 'string', + description: + 'Optional workflow ID to edit. If not provided, uses the current workflow in context.', + }, + }, + required: ['blockId', 'enabled'], + }, + requiredPermission: 'write', +} + export const SetEnvironmentVariables: ToolCatalogEntry = { id: 'set_environment_variables', name: 'set_environment_variables', @@ -3055,7 +3056,6 @@ export const TOOL_CATALOG: Record = { [CreateJob.id]: CreateJob, [CreateWorkflow.id]: CreateWorkflow, [CreateWorkspaceMcpServer.id]: CreateWorkspaceMcpServer, - [Debug.id]: Debug, [DeleteFile.id]: DeleteFile, [DeleteFolder.id]: DeleteFolder, [DeleteWorkflow.id]: DeleteWorkflow, @@ -3119,6 +3119,7 @@ export const TOOL_CATALOG: Record = { [SearchLibraryDocs.id]: SearchLibraryDocs, [SearchOnline.id]: SearchOnline, [SearchPatterns.id]: SearchPatterns, + [SetBlockEnabled.id]: SetBlockEnabled, [SetEnvironmentVariables.id]: SetEnvironmentVariables, [SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables, [Superagent.id]: Superagent, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 19b4e260b1..01ec838235 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -266,25 +266,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - debug: { - parameters: { - properties: { - context: { - description: - 'Pre-gathered context: workflow state JSON, block schemas, error logs. The debug agent will skip re-reading anything included here.', - type: 'string', - }, - request: { - description: - 'What to debug. Include error messages, block IDs, and any context about the failure.', - type: 'string', - }, - }, - required: ['request'], - type: 'object', - }, - resultSchema: undefined, - }, delete_file: { parameters: { type: 'object', @@ -2078,6 +2059,28 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + set_block_enabled: { + parameters: { + type: 'object', + properties: { + blockId: { + type: 'string', + description: 'The block ID whose enabled state should be changed.', + }, + enabled: { + type: 'boolean', + description: 'Set to true to enable the block, or false to disable it.', + }, + workflowId: { + type: 'string', + description: + 'Optional workflow ID to edit. If not provided, uses the current workflow in context.', + }, + }, + required: ['blockId', 'enabled'], + }, + resultSchema: undefined, + }, set_environment_variables: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index a5676de686..10443057fc 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -127,6 +127,33 @@ describe('sse-handlers tool lifecycle', () => { expect(updated?.result?.output).toEqual({ ok: true }) }) + it('does not add hidden tool calls to content blocks', async () => { + executeTool.mockResolvedValueOnce({ success: true, output: { skill: 'ok' } }) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-hidden', + toolName: 'load_agent_skill', + arguments: { skill_name: 'markdown-writing' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(executeTool).toHaveBeenCalledTimes(1) + expect(context.contentBlocks).toEqual([]) + expect(context.toolCalls.get('tool-hidden')?.name).toBe('load_agent_skill') + }) + it('updates stored params when a subagent generating event is followed by the final tool call', async () => { executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) context.subAgentParentToolCallId = 'parent-1' diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index dec3766dc9..f990a303ab 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -22,6 +22,7 @@ import type { ToolCallState, } from '@/lib/copilot/request/types' import { getToolEntry, isSimExecuted } from '@/lib/copilot/tool-executor' +import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools' import type { ToolScope } from './types' import { @@ -235,6 +236,7 @@ function registerSubagentToolCall( if (!context.subAgentToolCalls[parentToolCallId]) { context.subAgentToolCalls[parentToolCallId] = [] } + const hideFromUi = isToolHiddenInUi(toolName) let toolCall = context.toolCalls.get(toolCallId) if (toolCall) { if (!toolCall.name && toolName) toolCall.name = toolName @@ -249,11 +251,13 @@ function registerSubagentToolCall( } context.toolCalls.set(toolCallId, toolCall) const parentToolCall = context.toolCalls.get(parentToolCallId) - addContentBlock(context, { - type: 'tool_call', - toolCall, - calledBy: parentToolCall?.name, - }) + if (!hideFromUi) { + addContentBlock(context, { + type: 'tool_call', + toolCall, + calledBy: parentToolCall?.name, + }) + } } const subagentToolCalls = context.subAgentToolCalls[parentToolCallId] @@ -273,9 +277,11 @@ function registerMainToolCall( args: Record | undefined, existing: ToolCallState | undefined ): void { + const hideFromUi = isToolHiddenInUi(toolName) if (existing) { if (args && !existing.params) existing.params = args if ( + !hideFromUi && !context.contentBlocks.some((b) => b.type === 'tool_call' && b.toolCall?.id === toolCallId) ) { addContentBlock(context, { type: 'tool_call', toolCall: existing }) @@ -289,7 +295,9 @@ function registerMainToolCall( startTime: Date.now(), } context.toolCalls.set(toolCallId, created) - addContentBlock(context, { type: 'tool_call', toolCall: created }) + if (!hideFromUi) { + addContentBlock(context, { type: 'tool_call', toolCall: created }) + } } } diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 5958b21eb1..00f2dddb0b 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -384,6 +384,7 @@ async function buildExecutionContext( const { userId, workflowId, workspaceId, chatId, executionId, runId, abortSignal } = params const userTimezone = typeof requestPayload?.userTimezone === 'string' ? requestPayload.userTimezone : undefined + const requestMode = typeof requestPayload?.mode === 'string' ? requestPayload.mode : undefined let execContext: ExecutionContext if (workflowId) { @@ -400,6 +401,8 @@ async function buildExecutionContext( } if (userTimezone) execContext.userTimezone = userTimezone + execContext.copilotToolExecution = true + if (requestMode) execContext.requestMode = requestMode execContext.executionId = executionId execContext.runId = runId execContext.abortSignal = abortSignal diff --git a/apps/sim/lib/copilot/request/trace.ts b/apps/sim/lib/copilot/request/trace.ts index 72353f1984..8f74f74376 100644 --- a/apps/sim/lib/copilot/request/trace.ts +++ b/apps/sim/lib/copilot/request/trace.ts @@ -25,10 +25,13 @@ export class TraceCollector { attributes?: Record, parent?: RequestTraceV1Span ): RequestTraceV1Span { + const startMs = Date.now() const span: RequestTraceV1Span = { name, kind, - startMs: Date.now(), + startMs, + endMs: startMs, + durationMs: 0, status: RequestTraceV1SpanStatus.ok, source: RequestTraceV1SpanSource.sim, ...(parent diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts index 9a20e90a5f..f35a1116ee 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -113,6 +113,9 @@ function buildAppToolParams( chatId: context.chatId, executionId: context.executionId, runId: context.runId, + copilotToolExecution: context.copilotToolExecution, + requestMode: context.requestMode, + currentAgentId: context.currentAgentId, enforceCredentialAccess: true, } diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index f354b8fe2d..95433dabde 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -45,6 +45,7 @@ import { RunFromBlock, RunWorkflow, RunWorkflowUntilBlock, + SetBlockEnabled, SetGlobalWorkflowVariables, UpdateJobHistory, UpdateWorkspaceMcpServer, @@ -96,6 +97,7 @@ import { executeRunFromBlock, executeRunWorkflow, executeRunWorkflowUntilBlock, + executeSetBlockEnabled, executeSetGlobalWorkflowVariables, } from '../tools/handlers/workflow/mutations' import { @@ -147,6 +149,7 @@ function buildHandlerMap(): Record { [RunWorkflowUntilBlock.id]: h(executeRunWorkflowUntilBlock), [RunFromBlock.id]: h(executeRunFromBlock), [RunBlock.id]: h(executeRunBlock), + [SetBlockEnabled.id]: h(executeSetBlockEnabled), [GenerateApiKey.id]: h(executeGenerateApiKey), [SetGlobalWorkflowVariables.id]: h(executeSetGlobalWorkflowVariables), diff --git a/apps/sim/lib/copilot/tool-executor/types.ts b/apps/sim/lib/copilot/tool-executor/types.ts index 60ecda8db4..53f43634cc 100644 --- a/apps/sim/lib/copilot/tool-executor/types.ts +++ b/apps/sim/lib/copilot/tool-executor/types.ts @@ -7,6 +7,9 @@ export interface ToolExecutionContext { chatId?: string executionId?: string runId?: string + copilotToolExecution?: boolean + requestMode?: string + currentAgentId?: string abortSignal?: AbortSignal userTimezone?: string userPermission?: string diff --git a/apps/sim/lib/copilot/tools/client/hidden-tools.ts b/apps/sim/lib/copilot/tools/client/hidden-tools.ts new file mode 100644 index 0000000000..d50d73c195 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/hidden-tools.ts @@ -0,0 +1,9 @@ +const HIDDEN_TOOL_NAMES = new Set(['tool_search_tool_regex', 'load_agent_skill']) + +export function isToolHiddenInUi(toolName: string | undefined): boolean { + return !!toolName && HIDDEN_TOOL_NAMES.has(toolName) +} + +export function getHiddenToolNames(): ReadonlySet { + return HIDDEN_TOOL_NAMES +} diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts index 60f09356d9..299764201a 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.ts @@ -25,6 +25,7 @@ import { } from 'lucide-react' import { Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types' +import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { ClientToolCallState, type ClientToolDisplay, @@ -36,8 +37,6 @@ const logger = createLogger('CopilotStoreUtils') /** Respond tools are internal handoff tools shown with a friendly generic label. */ const HIDDEN_TOOL_SUFFIX = '_respond' const INTERNAL_RESPOND_TOOL = 'respond' -const HIDDEN_TOOL_NAMES = new Set(['tool_search_tool_regex']) - /** UI metadata sent by the copilot on SSE tool_call events. */ export interface ServerToolUI { title?: string @@ -85,7 +84,7 @@ export function resolveToolDisplay( serverUI?: ServerToolUI ): ClientToolDisplay | undefined { if (!toolName) return undefined - if (HIDDEN_TOOL_NAMES.has(toolName)) return undefined + if (isToolHiddenInUi(toolName)) return undefined const specialDisplay = specialToolDisplay(toolName, state, params) if (specialDisplay) return specialDisplay diff --git a/apps/sim/lib/copilot/tools/client/tool-display-registry.ts b/apps/sim/lib/copilot/tools/client/tool-display-registry.ts index 204790492f..e5fcaec372 100644 --- a/apps/sim/lib/copilot/tools/client/tool-display-registry.ts +++ b/apps/sim/lib/copilot/tools/client/tool-display-registry.ts @@ -1,7 +1,6 @@ import type { LucideIcon } from 'lucide-react' import { BookOpen, - Bug, Check, CheckCircle, Database, @@ -2126,26 +2125,6 @@ const META_superagent: ToolMetadata = { }, } -const META_debug: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Debugging', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Debugging', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Debugging', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Debugged', icon: Bug }, - [ClientToolCallState.error]: { text: 'Failed to debug', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped debugging', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted debugging', icon: XCircle }, - }, - uiConfig: { - subagent: { - streamingLabel: 'Debugging', - completedLabel: 'Debugged', - shouldCollapse: true, - outputArtifacts: [], - }, - }, -} - const META_table: ToolMetadata = { displayNames: { [ClientToolCallState.generating]: { text: 'Managing tables', icon: Loader2 }, @@ -2345,7 +2324,6 @@ const TOOL_METADATA_BY_ID: Record = { create_workflow: META_create_workflow, agent: META_agent, custom_tool: META_custom_tool, - debug: META_debug, deploy: META_deploy, deploy_api: META_deploy_api, deploy_chat: META_deploy_chat, diff --git a/apps/sim/lib/copilot/tools/handlers/param-types.ts b/apps/sim/lib/copilot/tools/handlers/param-types.ts index 4e56d5c4ce..d353850f0d 100644 --- a/apps/sim/lib/copilot/tools/handlers/param-types.ts +++ b/apps/sim/lib/copilot/tools/handlers/param-types.ts @@ -103,6 +103,12 @@ export interface SetGlobalWorkflowVariablesParams { operations?: VariableOperation[] } +export interface SetBlockEnabledParams { + workflowId?: string + blockId: string + enabled: boolean +} + // === Deployment Params === export interface DeployApiParams { diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index ed3ee1c3b4..a0b25b0605 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -1,7 +1,10 @@ +import { db, workflow as workflowTable } from '@sim/db' import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' import { createWorkspaceApiKey } from '@/lib/api-key/auth' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow' @@ -10,7 +13,10 @@ import { getLatestExecutionState, } from '@/lib/workflows/executor/execution-state' import { performDeleteFolder, performDeleteWorkflow } from '@/lib/workflows/orchestration' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { + loadWorkflowFromNormalizedTables, + saveWorkflowToNormalizedTables, +} from '@/lib/workflows/persistence/utils' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { checkForCircularReference, @@ -22,6 +28,7 @@ import { updateWorkflowRecord, } from '@/lib/workflows/utils' import { hasExecutionResult } from '@/executor/utils/errors' +import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' import { ensureWorkflowAccess, ensureWorkspaceAccess, getDefaultWorkspaceId } from '../access' function stripBinaryFields(value: unknown): unknown { @@ -71,6 +78,72 @@ function buildExecutionError(error: unknown): ToolCallResult { return { success: false, error: message } } +function isBlockProtected(blockId: string, blocksById: Record): boolean { + const block = blocksById[blockId] + if (!block) return false + if (block.locked) return true + + const visited = new Set() + let parentId = block.data?.parentId + while (parentId && !visited.has(parentId)) { + visited.add(parentId) + if (blocksById[parentId]?.locked) return true + parentId = blocksById[parentId]?.data?.parentId + } + + return false +} + +function hasDisabledAncestor(blockId: string, blocksById: Record): boolean { + const visited = new Set() + let parentId = blocksById[blockId]?.data?.parentId + + while (parentId && !visited.has(parentId)) { + visited.add(parentId) + const parent = blocksById[parentId] + if (!parent) return false + if (parent.enabled === false) return true + parentId = parent.data?.parentId + } + + return false +} + +function findDescendants(containerId: string, blocksById: Record): string[] { + const descendants: string[] = [] + const stack = [containerId] + const visited = new Set() + + while (stack.length > 0) { + const current = stack.pop()! + if (visited.has(current)) continue + visited.add(current) + + for (const [blockId, block] of Object.entries(blocksById)) { + if (block.data?.parentId === current) { + descendants.push(blockId) + stack.push(blockId) + } + } + } + + return descendants +} + +function notifyWorkflowUpdated(workflowId: string): void { + const socketUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002' + fetch(`${socketUrl}/api/workflow-updated`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': env.INTERNAL_API_SECRET, + }, + body: JSON.stringify({ workflowId }), + }).catch((error) => { + logger.warn('Failed to notify socket server of workflow update', { workflowId, error }) + }) +} + import type { CreateFolderParams, CreateWorkflowParams, @@ -85,6 +158,7 @@ import type { RunFromBlockParams, RunWorkflowParams, RunWorkflowUntilBlockParams, + SetBlockEnabledParams, SetGlobalWorkflowVariablesParams, UpdateWorkflowParams, VariableOperation, @@ -666,6 +740,137 @@ export async function executeUpdateWorkflow( } } +export async function executeSetBlockEnabled( + params: SetBlockEnabledParams, + context: ExecutionContext +): Promise { + try { + const workflowId = params.workflowId || context.workflowId + if (!workflowId) { + return { success: false, error: 'workflowId is required' } + } + if (!params.blockId) { + return { success: false, error: 'blockId is required' } + } + if (typeof params.enabled !== 'boolean') { + return { success: false, error: 'enabled must be a boolean' } + } + + const { workflow: workflowRecord } = await ensureWorkflowAccess( + workflowId, + context.userId, + 'write' + ) + assertWorkflowMutationNotAborted(context) + + const normalized = await loadWorkflowFromNormalizedTables(workflowId) + if (!normalized) { + return { success: false, error: `Workflow ${workflowId} has no normalized state` } + } + + const currentState: WorkflowState = { + blocks: normalized.blocks as Record, + edges: normalized.edges || [], + loops: normalized.loops || {}, + parallels: normalized.parallels || {}, + lastSaved: Date.now(), + } + + const currentBlocks = currentState.blocks + const targetBlock = currentBlocks[params.blockId] + if (!targetBlock) { + return { + success: false, + error: `Block ${params.blockId} not found in workflow ${workflowId}`, + } + } + if (isBlockProtected(params.blockId, currentBlocks)) { + return { + success: false, + error: `Block ${params.blockId} is locked or inside a locked container and cannot be updated`, + } + } + if (targetBlock.enabled === params.enabled) { + return { + success: true, + output: { + workflowId, + workflowName: workflowRecord.name, + blockId: params.blockId, + enabled: params.enabled, + affectedBlockIds: [params.blockId], + workflowState: currentState, + copilotSanitizedWorkflowState: sanitizeForCopilot(currentState), + message: `Block ${params.blockId} is already ${params.enabled ? 'enabled' : 'disabled'}`, + }, + } + } + if (params.enabled && hasDisabledAncestor(params.blockId, currentBlocks)) { + return { + success: false, + error: `Cannot enable block ${params.blockId} while one of its parent containers is disabled. Enable the parent first.`, + } + } + + const affectedBlockIds = new Set([params.blockId]) + if (targetBlock.type === 'loop' || targetBlock.type === 'parallel') { + for (const descendantId of findDescendants(params.blockId, currentBlocks)) { + if (!isBlockProtected(descendantId, currentBlocks)) { + affectedBlockIds.add(descendantId) + } + } + } + + const nextBlocks: Record = { ...currentBlocks } + for (const blockId of affectedBlockIds) { + nextBlocks[blockId] = { + ...nextBlocks[blockId], + enabled: params.enabled, + } + } + + const nextState: WorkflowState = { + ...currentState, + blocks: nextBlocks, + lastSaved: Date.now(), + } + + assertWorkflowMutationNotAborted(context) + const saveResult = await saveWorkflowToNormalizedTables(workflowId, nextState) + if (!saveResult.success) { + return { + success: false, + error: saveResult.error || `Failed to persist enabled state for block ${params.blockId}`, + } + } + + await db + .update(workflowTable) + .set({ + lastSynced: new Date(), + updatedAt: new Date(), + }) + .where(eq(workflowTable.id, workflowId)) + + notifyWorkflowUpdated(workflowId) + + return { + success: true, + output: { + workflowId, + workflowName: workflowRecord.name, + blockId: params.blockId, + enabled: params.enabled, + affectedBlockIds: Array.from(affectedBlockIds), + workflowState: nextState, + copilotSanitizedWorkflowState: sanitizeForCopilot(nextState), + }, + } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } +} + export async function executeDeleteWorkflow( params: DeleteWorkflowParams, context: ExecutionContext diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts index 75755a59ee..1c8dd8a50d 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts @@ -4,6 +4,7 @@ import { mcpService } from '@/lib/mcp/service' import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' import { getEffectiveBlockOutputPaths } from '@/lib/workflows/blocks/block-outputs' import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator' +import { getBlockReferenceTags } from '@/lib/workflows/blocks/block-reference-tags' import { listCustomTools } from '@/lib/workflows/custom-tools/operations' import { loadDeployedWorkflowState, @@ -335,27 +336,29 @@ export async function executeGetBlockUpstreamReferences( const blockName = block.name || block.type let accessContext: 'inside' | 'outside' | undefined - let outputPaths: string[] + let formattedOutputs: string[] if (block.type === 'loop' || block.type === 'parallel') { const isInside = (block.type === 'loop' && containingLoopIds.has(accessibleBlockId)) || (block.type === 'parallel' && containingParallelIds.has(accessibleBlockId)) accessContext = isInside ? 'inside' : 'outside' - outputPaths = isInside + const outputPaths = isInside ? getSubflowInsidePaths(block.type, accessibleBlockId, loops, parallels) : ['results'] + formattedOutputs = formatOutputsWithPrefix(outputPaths, blockName) } else { - const blockConfig = getBlock(block.type) - const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false - const triggerMode = Boolean(block.triggerMode && isTriggerCapable) - outputPaths = getEffectiveBlockOutputPaths(block.type, block.subBlocks, { - triggerMode, - preferToolOutputs: !triggerMode, + formattedOutputs = getBlockReferenceTags({ + block: { + id: accessibleBlockId, + type: block.type, + name: block.name, + triggerMode: block.triggerMode, + subBlocks: block.subBlocks, + }, + currentBlockId: blockId, }) } - - const formattedOutputs = formatOutputsWithPrefix(outputPaths, blockName) const entry: AccessibleBlockEntry = { blockId: accessibleBlockId, blockName, diff --git a/apps/sim/lib/copilot/tools/mcp/definitions.ts b/apps/sim/lib/copilot/tools/mcp/definitions.ts index 22b2e1d516..52ba61daed 100644 --- a/apps/sim/lib/copilot/tools/mcp/definitions.ts +++ b/apps/sim/lib/copilot/tools/mcp/definitions.ts @@ -397,22 +397,6 @@ Supports full and partial execution: }, annotations: { destructiveHint: false, openWorldHint: true }, }, - { - name: 'sim_debug', - agentId: 'debug', - description: - 'Diagnose errors or unexpected workflow behavior. Provide the error message and workflowId. Returns root cause analysis and fix suggestions.', - inputSchema: { - type: 'object', - properties: { - error: { type: 'string', description: 'The error message or description of the issue.' }, - workflowId: { type: 'string', description: 'REQUIRED. The workflow ID to debug.' }, - context: { type: 'object' }, - }, - required: ['error', 'workflowId'], - }, - annotations: { readOnlyHint: true }, - }, { name: 'sim_auth', agentId: 'auth', diff --git a/apps/sim/lib/workflows/blocks/block-reference-tags.test.ts b/apps/sim/lib/workflows/blocks/block-reference-tags.test.ts new file mode 100644 index 0000000000..790f8597ac --- /dev/null +++ b/apps/sim/lib/workflows/blocks/block-reference-tags.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetBlock } = vi.hoisted(() => ({ + mockGetBlock: vi.fn(), +})) + +vi.mock('@/blocks/registry', () => ({ + getBlock: mockGetBlock, + getAllBlocks: vi.fn(() => ({})), +})) + +import { getBlockReferenceTags } from '@/lib/workflows/blocks/block-reference-tags' + +describe('getBlockReferenceTags', () => { + beforeEach(() => { + mockGetBlock.mockReset() + mockGetBlock.mockReturnValue({ + outputs: { + content: { type: 'string' }, + model: { type: 'string' }, + }, + subBlocks: [], + }) + }) + + it('returns agent responseFormat fields instead of default outputs', () => { + const tags = getBlockReferenceTags({ + block: { + id: 'agent-1', + type: 'agent', + name: 'Classify Email', + subBlocks: { + responseFormat: { + value: { + name: 'email_classification', + schema: { + type: 'object', + properties: { + isImportant: { type: 'boolean' }, + draftReply: { type: 'string' }, + reason: { type: 'string' }, + }, + required: ['isImportant', 'draftReply', 'reason'], + additionalProperties: false, + }, + strict: true, + }, + }, + }, + }, + }) + + expect(tags).toEqual([ + 'classifyemail.isImportant', + 'classifyemail.draftReply', + 'classifyemail.reason', + ]) + }) + + it('returns variables block assignments as block tags', () => { + const tags = getBlockReferenceTags({ + block: { + id: 'variables-1', + type: 'variables', + name: 'Workflow Vars', + subBlocks: { + variables: { + value: [{ variableName: 'currentDraft' }, { variableName: 'needsRevision' }], + }, + }, + }, + }) + + expect(tags).toEqual(['workflowvars.currentDraft', 'workflowvars.needsRevision']) + }) +}) diff --git a/apps/sim/lib/workflows/blocks/block-reference-tags.ts b/apps/sim/lib/workflows/blocks/block-reference-tags.ts new file mode 100644 index 0000000000..f48fb20a50 --- /dev/null +++ b/apps/sim/lib/workflows/blocks/block-reference-tags.ts @@ -0,0 +1,83 @@ +import { getEffectiveBlockOutputPaths } from '@/lib/workflows/blocks/block-outputs' +import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' +import { TRIGGER_TYPES } from '@/lib/workflows/triggers/triggers' +import { getBlock } from '@/blocks' +import { normalizeName } from '@/executor/constants' + +interface ReferenceableBlock { + id: string + type: string + name?: string + triggerMode?: boolean + subBlocks?: Record +} + +interface GetBlockReferenceTagsOptions { + block: ReferenceableBlock + currentBlockId?: string + subBlocks?: Record +} + +/** + * Returns the exact reference tags shown in the workflow tag dropdown for a block. + */ +export function getBlockReferenceTags({ + block, + currentBlockId, + subBlocks, +}: GetBlockReferenceTagsOptions): string[] { + const blockName = block.name || block.type + const normalizedBlockName = normalizeName(blockName) + const mergedSubBlocks = subBlocks ?? block.subBlocks + + if (block.type === 'variables') { + const variablesValue = mergedSubBlocks?.variables?.value + if (Array.isArray(variablesValue) && variablesValue.length > 0) { + const validAssignments = variablesValue.filter((assignment: { variableName?: string }) => + assignment?.variableName?.trim() + ) + if (validAssignments.length > 0) { + return validAssignments.map( + (assignment: { variableName: string }) => + `${normalizedBlockName}.${assignment.variableName.trim()}` + ) + } + } + + return [normalizedBlockName] + } + + const blockConfig = getBlock(block.type) + if (!blockConfig) { + return [] + } + + const isTriggerCapable = hasTriggerCapability(blockConfig) + const effectiveTriggerMode = Boolean(block.triggerMode && isTriggerCapable) + const outputPaths = getEffectiveBlockOutputPaths(block.type, mergedSubBlocks, { + triggerMode: effectiveTriggerMode, + preferToolOutputs: !effectiveTriggerMode, + }) + const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`) + + let blockTags: string[] + if (block.type === 'human_in_the_loop' && block.id === currentBlockId) { + blockTags = allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint')) + } else if (allTags.length === 0) { + blockTags = [normalizedBlockName] + } else { + blockTags = allTags + } + + if (!blockTags.includes(normalizedBlockName)) { + blockTags = [normalizedBlockName, ...blockTags] + } + + const shouldShowRootTag = + block.type === TRIGGER_TYPES.GENERIC_WEBHOOK || block.type === 'start_trigger' + if (!shouldShowRootTag) { + blockTags = blockTags.filter((tag) => tag !== normalizedBlockName) + } + + return blockTags +} diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index cef7cc82a1..ed9e0197ac 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -28,6 +28,7 @@ const { mockGenerateInternalToken, mockSecureFetchWithPinnedIP, mockValidateUrlWithDNS, + mockResolveWorkspaceFileReference, } = vi.hoisted(() => ({ mockIsHosted: { value: false }, mockEnv: { NEXT_PUBLIC_APP_URL: 'http://localhost:3000' } as Record, @@ -44,6 +45,7 @@ const { mockGenerateInternalToken: vi.fn(), mockSecureFetchWithPinnedIP: vi.fn(), mockValidateUrlWithDNS: vi.fn(), + mockResolveWorkspaceFileReference: vi.fn(), })) // Mock feature flags @@ -86,6 +88,10 @@ vi.mock('@/lib/core/rate-limiter/hosted-key', () => ({ getHostedKeyRateLimiter: () => mockRateLimiterFns, })) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + resolveWorkspaceFileReference: (...args: unknown[]) => mockResolveWorkspaceFileReference(...args), +})) + // Mock the tools registry to avoid loading the full 4500+ line registry file. // Only the tools actually exercised in tests are provided. vi.mock('@/tools/registry', () => { @@ -188,6 +194,44 @@ vi.mock('@/tools/registry', () => { params: {}, request: { url: '/api/tools/gmail/send', method: 'POST' }, }, + test_single_file_tool: { + id: 'test_single_file_tool', + name: 'Test Single File Tool', + description: 'Accepts a single file parameter', + version: '1.0.0', + params: { + attachment: { type: 'file', required: true }, + }, + request: { + url: '/api/tools/test/single-file', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (p: any) => ({ attachment: p.attachment }), + }, + transformResponse: async (response: any) => { + const data = await response.json() + return { success: true, output: data } + }, + }, + test_file_array_tool: { + id: 'test_file_array_tool', + name: 'Test File Array Tool', + description: 'Accepts an array of file parameters', + version: '1.0.0', + params: { + attachments: { type: 'file[]', required: true }, + }, + request: { + url: '/api/tools/test/file-array', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (p: any) => ({ attachments: p.attachments }), + }, + transformResponse: async (response: any) => { + const data = await response.json() + return { success: true, output: data } + }, + }, google_drive_list: { id: 'google_drive_list', name: 'Google Drive List', @@ -747,6 +791,197 @@ describe('Automatic Internal Route Detection', () => { }) }) +describe('Copilot File Parameter Normalization', () => { + let cleanupEnvVars: () => void + + beforeEach(() => { + process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' + cleanupEnvVars = setupEnvVars({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) + mockResolveWorkspaceFileReference.mockReset() + }) + + afterEach(() => { + vi.resetAllMocks() + cleanupEnvVars() + }) + + it('resolves canonical file IDs for single-file params during copilot execution', async () => { + mockResolveWorkspaceFileReference.mockResolvedValue({ + id: 'wf_123', + name: 'brief.pdf', + path: '/api/files/wf_123', + size: 512, + type: 'application/pdf', + key: 'uploads/wf_123', + }) + + global.fetch = Object.assign( + vi.fn().mockImplementation(async (_url, options) => { + const body = JSON.parse(options?.body as string) + expect(body.attachment).toEqual({ + id: 'wf_123', + name: 'brief.pdf', + url: '/api/files/wf_123', + size: 512, + type: 'application/pdf', + key: 'uploads/wf_123', + context: 'workspace', + }) + + return { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + json: () => Promise.resolve({ ok: true }), + text: () => Promise.resolve(JSON.stringify({ ok: true })), + clone: vi.fn().mockReturnThis(), + } + }), + { preconnect: vi.fn() } + ) as typeof fetch + + const context = createToolExecutionContext({ + workspaceId: 'workspace-456', + copilotToolExecution: true, + } as any) + + const result = await executeTool( + 'test_single_file_tool', + { attachment: 'wf_123' }, + false, + context + ) + + expect(result.success).toBe(true) + expect(mockResolveWorkspaceFileReference).toHaveBeenCalledWith('workspace-456', 'wf_123') + }) + + it('resolves file-array params from strings and partial file objects, while preserving full file objects', async () => { + mockResolveWorkspaceFileReference.mockImplementation( + async (_workspaceId: string, fileId: string) => ({ + id: fileId, + name: `${fileId}.txt`, + path: `/api/files/${fileId}`, + size: 128, + type: 'text/plain', + key: `uploads/${fileId}`, + }) + ) + + const existingFileObject = { + id: 'wf_existing', + name: 'existing.txt', + url: '/api/files/wf_existing', + size: 64, + type: 'text/plain', + key: 'uploads/wf_existing', + context: 'workspace', + } + + const partialFileObject = { + id: 'wf_partial', + name: 'partial.txt', + } + + global.fetch = Object.assign( + vi.fn().mockImplementation(async (_url, options) => { + const body = JSON.parse(options?.body as string) + expect(body.attachments).toEqual([ + { + id: 'wf_1', + name: 'wf_1.txt', + url: '/api/files/wf_1', + size: 128, + type: 'text/plain', + key: 'uploads/wf_1', + context: 'workspace', + }, + { + id: 'wf_partial', + name: 'wf_partial.txt', + url: '/api/files/wf_partial', + size: 128, + type: 'text/plain', + key: 'uploads/wf_partial', + context: 'workspace', + }, + existingFileObject, + { + id: 'wf_2', + name: 'wf_2.txt', + url: '/api/files/wf_2', + size: 128, + type: 'text/plain', + key: 'uploads/wf_2', + context: 'workspace', + }, + ]) + + return { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + json: () => Promise.resolve({ ok: true }), + text: () => Promise.resolve(JSON.stringify({ ok: true })), + clone: vi.fn().mockReturnThis(), + } + }), + { preconnect: vi.fn() } + ) as typeof fetch + + const context = createToolExecutionContext({ + workspaceId: 'workspace-456', + copilotToolExecution: true, + } as any) + + const result = await executeTool( + 'test_file_array_tool', + { attachments: ['wf_1', partialFileObject, existingFileObject, 'wf_2'] }, + false, + context + ) + + expect(result.success).toBe(true) + expect(mockResolveWorkspaceFileReference).toHaveBeenCalledTimes(3) + }) + + it('does not resolve file params outside copilot execution', async () => { + global.fetch = Object.assign( + vi.fn().mockImplementation(async (_url, options) => { + const body = JSON.parse(options?.body as string) + expect(body.attachment).toBe('wf_123') + + return { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + json: () => Promise.resolve({ ok: true }), + text: () => Promise.resolve(JSON.stringify({ ok: true })), + clone: vi.fn().mockReturnThis(), + } + }), + { preconnect: vi.fn() } + ) as typeof fetch + + const context = createToolExecutionContext({ + workspaceId: 'workspace-456', + } as any) + + const result = await executeTool( + 'test_single_file_tool', + { attachment: 'wf_123' }, + false, + context + ) + + expect(result.success).toBe(true) + expect(mockResolveWorkspaceFileReference).not.toHaveBeenCalled() + }) +}) + describe('Centralized Error Handling', () => { let cleanupEnvVars: () => void diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 1653443047..6062417a07 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -11,11 +11,13 @@ import { import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl, getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { isUserFile } from '@/lib/core/utils/user-file' import { SIM_VIA_HEADER, serializeCallChain } from '@/lib/execution/call-chain' import { parseMcpToolId } from '@/lib/mcp/utils' +import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { isCustomTool, isMcpTool } from '@/executor/constants' import { resolveSkillContent } from '@/executor/handlers/agent/skills-resolver' -import type { ExecutionContext } from '@/executor/types' +import type { ExecutionContext, UserFile } from '@/executor/types' import type { ErrorInfo } from '@/tools/error-extractors' import { extractErrorMessage } from '@/tools/error-extractors' import type { @@ -39,6 +41,7 @@ interface ToolExecutionScope { callChain?: string[] isDeployedContext?: boolean enforceCredentialAccess?: boolean + copilotToolExecution?: boolean } function resolveToolScope( @@ -57,6 +60,108 @@ function resolveToolScope( | undefined, enforceCredentialAccess: (executionContext?.enforceCredentialAccess ?? ctx?.enforceCredentialAccess) as boolean | undefined, + copilotToolExecution: (executionContext?.copilotToolExecution ?? ctx?.copilotToolExecution) as + | boolean + | undefined, + } +} + +function toUserFileFromWorkspaceRecord(record: { + id: string + name: string + path: string + url?: string + size: number + type: string + key: string +}): UserFile { + return { + id: record.id, + name: record.name, + url: record.url ?? record.path, + size: record.size, + type: record.type, + key: record.key, + context: 'workspace', + } +} + +async function resolveCopilotFileReference( + value: unknown, + workspaceId: string, + paramId: string +): Promise { + if (isUserFile(value)) { + return value + } + + const referenceId = + typeof value === 'string' + ? value + : value && + typeof value === 'object' && + typeof (value as Record).id === 'string' + ? ((value as Record).id as string) + : null + + if (!referenceId) { + return value + } + + const fileRecord = await resolveWorkspaceFileReference(workspaceId, referenceId) + if (!fileRecord) { + throw new Error( + `Could not resolve workspace file reference "${referenceId}" for parameter "${paramId}"` + ) + } + + const resolvedFile = toUserFileFromWorkspaceRecord(fileRecord) + if (!value || typeof value !== 'object') { + return resolvedFile + } + + const candidate = value as Record + return { + ...resolvedFile, + context: typeof candidate.context === 'string' ? candidate.context : resolvedFile.context, + base64: typeof candidate.base64 === 'string' ? candidate.base64 : undefined, + } +} + +async function normalizeCopilotFileParams( + tool: ToolConfig, + params: Record, + scope: ToolExecutionScope +): Promise { + if (!scope.copilotToolExecution) { + return + } + + for (const [paramId, paramDef] of Object.entries(tool.params || {})) { + const paramType = paramDef?.type + const currentValue = params[paramId] + if (currentValue === undefined || currentValue === null) { + continue + } + + if (paramType === 'file') { + if (!scope.workspaceId) { + throw new Error(`Missing workspaceId while resolving file parameter "${paramId}"`) + } + params[paramId] = await resolveCopilotFileReference(currentValue, scope.workspaceId, paramId) + continue + } + + if (paramType === 'file[]') { + if (!scope.workspaceId) { + throw new Error(`Missing workspaceId while resolving file parameter "${paramId}"`) + } + + const values = Array.isArray(currentValue) ? currentValue : [currentValue] + params[paramId] = await Promise.all( + values.map((item) => resolveCopilotFileReference(item, scope.workspaceId!, paramId)) + ) + } } } @@ -683,6 +788,8 @@ export async function executeTool( throw new Error(`Tool not found: ${toolId}`) } + await normalizeCopilotFileParams(tool, contextParams, scope) + // Inject hosted API key if tool supports it and user didn't provide one const hostedKeyInfo = await injectHostedKeyIfNeeded( tool, diff --git a/apps/sim/tools/params.test.ts b/apps/sim/tools/params.test.ts index fff2c780b1..8aea57cd2e 100644 --- a/apps/sim/tools/params.test.ts +++ b/apps/sim/tools/params.test.ts @@ -141,6 +141,61 @@ describe('Tool Parameters Utils', () => { expect(schema.required).not.toContain('accessToken') expect(schema.properties).toHaveProperty('message') }) + + it.concurrent('keeps shared file params unchanged by default', () => { + const toolWithFileParam = { + ...mockToolConfig, + id: 'file_schema_tool', + params: { + attachment: { + type: 'file', + required: true, + visibility: 'user-or-llm' as ParameterVisibility, + description: 'Attachment file', + }, + }, + } + + const schema = createUserToolSchema(toolWithFileParam) + + expect(schema.properties.attachment).toMatchObject({ + type: 'file', + description: 'Attachment file', + }) + }) + + it.concurrent('expands file params for copilot-facing schemas', () => { + const toolWithFileParams = { + ...mockToolConfig, + id: 'copilot_file_schema_tool', + params: { + attachment: { + type: 'file', + required: true, + visibility: 'user-or-llm' as ParameterVisibility, + description: 'Attachment file', + }, + attachments: { + type: 'file[]', + required: false, + visibility: 'user-or-llm' as ParameterVisibility, + description: 'Attachment files', + }, + }, + } + + const schema = createUserToolSchema(toolWithFileParams, { surface: 'copilot' }) + + expect(schema.properties.attachment).toMatchObject({ + type: 'object', + required: ['id', 'name', 'url', 'size', 'type', 'key'], + }) + expect(schema.properties.attachment.description).toContain('canonical workspace file IDs') + expect(schema.properties.attachments).toMatchObject({ + type: 'array', + }) + expect(schema.properties.attachments.description).toContain('canonical workspace file IDs') + }) }) describe('createExecutionToolSchema', () => { diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index 4c7daf69cf..fed2d47f74 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -123,6 +123,10 @@ export interface ToolSchema { required: string[] } +export interface UserToolSchemaOptions { + surface?: 'default' | 'copilot' +} + export interface LLMToolSchemaResult { schema: ToolSchema enrichedDescription?: string @@ -390,8 +394,15 @@ export function getToolParametersConfig( function buildParameterSchema( toolId: string, paramId: string, - param: ToolParamDefinition + 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' @@ -416,7 +427,50 @@ function buildParameterSchema( return propertySchema } -export function createUserToolSchema(toolConfig: ToolConfig): ToolSchema { +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 schema: ToolSchema = { type: 'object', properties: {}, @@ -430,7 +484,7 @@ export function createUserToolSchema(toolConfig: ToolConfig): ToolSchema { continue } - const propertySchema = buildParameterSchema(toolConfig.id, paramId, param) + const propertySchema = buildParameterSchema(toolConfig.id, paramId, param, options) schema.properties[paramId] = propertySchema if (param.required) {