diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx index 728277c31..9c5ca7b78 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx @@ -1239,8 +1239,8 @@ function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean { return true } - const mode = useCopilotStore.getState().mode - if (mode === 'build' && isIntegrationTool(toolCall.name)) { + // Integration tools (user-installed) always require approval + if (isIntegrationTool(toolCall.name)) { return true } diff --git a/apps/sim/lib/copilot/chat-payload.ts b/apps/sim/lib/copilot/chat-payload.ts index f6eefbab6..9f12f3730 100644 --- a/apps/sim/lib/copilot/chat-payload.ts +++ b/apps/sim/lib/copilot/chat-payload.ts @@ -155,7 +155,7 @@ export async function buildCopilotRequestPayload( messages.push({ role: 'user', content: message }) } - let integrationTools: ToolSchema[] = [] + const integrationTools: ToolSchema[] = [] let credentials: CredentialsPayload | null = null if (effectiveMode === 'build') { @@ -195,22 +195,29 @@ export async function buildCopilotRequestPayload( const { createUserToolSchema } = await import('@/tools/params') const latestTools = getLatestVersionTools(tools) - integrationTools = Object.entries(latestTools).map(([toolId, toolConfig]) => { - const userSchema = createUserToolSchema(toolConfig) - const strippedName = stripVersionSuffix(toolId) - return { - name: strippedName, - description: toolConfig.description || toolConfig.name || strippedName, - input_schema: userSchema as unknown as Record, - defer_loading: true, - ...(toolConfig.oauth?.required && { - oauth: { - required: true, - provider: toolConfig.oauth.provider, - }, - }), + for (const [toolId, toolConfig] of Object.entries(latestTools)) { + try { + const userSchema = createUserToolSchema(toolConfig) + const strippedName = stripVersionSuffix(toolId) + integrationTools.push({ + name: strippedName, + description: toolConfig.description || toolConfig.name || strippedName, + input_schema: userSchema as unknown as Record, + defer_loading: true, + ...(toolConfig.oauth?.required && { + oauth: { + required: true, + provider: toolConfig.oauth.provider, + }, + }), + }) + } catch (toolError) { + logger.warn('Failed to build schema for tool, skipping', { + toolId, + error: toolError instanceof Error ? toolError.message : String(toolError), + }) } - }) + } } catch (error) { logger.warn('Failed to build tool schemas for payload', { error: error instanceof Error ? error.message : String(error), diff --git a/apps/sim/lib/copilot/client-sse/handlers.ts b/apps/sim/lib/copilot/client-sse/handlers.ts index ac9f40fb4..cc294fb60 100644 --- a/apps/sim/lib/copilot/client-sse/handlers.ts +++ b/apps/sim/lib/copilot/client-sse/handlers.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { STREAM_STORAGE_KEY } from '@/lib/copilot/constants' +import { COPILOT_CONFIRM_API_PATH, STREAM_STORAGE_KEY } from '@/lib/copilot/constants' import { asRecord } from '@/lib/copilot/orchestrator/sse-utils' import type { SSEEvent } from '@/lib/copilot/orchestrator/types' import { @@ -24,6 +24,23 @@ const MAX_BATCH_INTERVAL = 50 const MIN_BATCH_INTERVAL = 16 const MAX_QUEUE_SIZE = 5 +/** + * Send an auto-accept confirmation to the server for auto-allowed tools. + * The server-side orchestrator polls Redis for this decision. + */ +export function sendAutoAcceptConfirmation(toolCallId: string): void { + fetch(COPILOT_CONFIRM_API_PATH, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ toolCallId, status: 'accepted' }), + }).catch((error) => { + logger.warn('Failed to send auto-accept confirmation', { + toolCallId, + error: error instanceof Error ? error.message : String(error), + }) + }) +} + function writeActiveStreamToStorage(info: CopilotStreamInfo | null): void { if (typeof window === 'undefined') return try { @@ -565,6 +582,12 @@ export const sseHandlers: Record = { return } + // Auto-allowed tools: send confirmation to the server so it can proceed + // without waiting for the user to click "Allow". + if (isAutoAllowed) { + sendAutoAcceptConfirmation(id) + } + // OAuth: dispatch event to open the OAuth connect modal if (toolName === 'oauth_request_access' && args && typeof window !== 'undefined') { try { diff --git a/apps/sim/lib/copilot/client-sse/subagent-handlers.ts b/apps/sim/lib/copilot/client-sse/subagent-handlers.ts index 2cdac76e2..aa07b21d3 100644 --- a/apps/sim/lib/copilot/client-sse/subagent-handlers.ts +++ b/apps/sim/lib/copilot/client-sse/subagent-handlers.ts @@ -9,7 +9,12 @@ import type { SSEEvent } from '@/lib/copilot/orchestrator/types' import { resolveToolDisplay } from '@/lib/copilot/store-utils' import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-display-registry' import type { CopilotStore, CopilotToolCall } from '@/stores/panel/copilot/types' -import { type SSEHandler, sseHandlers, updateStreamingMessage } from './handlers' +import { + type SSEHandler, + sendAutoAcceptConfirmation, + sseHandlers, + updateStreamingMessage, +} from './handlers' import type { ClientStreamingContext } from './types' const logger = createLogger('CopilotClientSubagentHandlers') @@ -234,6 +239,12 @@ export const subAgentSSEHandlers: Record = { if (isPartial) { return } + + // Auto-allowed tools: send confirmation to the server so it can proceed + // without waiting for the user to click "Allow". + if (isAutoAllowed) { + sendAutoAcceptConfirmation(id) + } }, tool_result: (data, context, get, set) => { diff --git a/apps/sim/lib/copilot/orchestrator/sse-handlers/handlers.ts b/apps/sim/lib/copilot/orchestrator/sse-handlers/handlers.ts index 8b5025897..111fe2047 100644 --- a/apps/sim/lib/copilot/orchestrator/sse-handlers/handlers.ts +++ b/apps/sim/lib/copilot/orchestrator/sse-handlers/handlers.ts @@ -8,6 +8,7 @@ import { wasToolResultSeen, } from '@/lib/copilot/orchestrator/sse-utils' import { + isIntegrationTool, isToolAvailableOnSimSide, markToolComplete, } from '@/lib/copilot/orchestrator/tool-executor' @@ -171,8 +172,10 @@ export const sseHandlers: Record = { const isInterruptTool = isInterruptToolName(toolName) const isInteractive = options.interactive === true + // Integration tools (user-installed) also require approval in interactive mode + const needsApproval = isInterruptTool || isIntegrationTool(toolName) - if (isInterruptTool && isInteractive) { + if (needsApproval && isInteractive) { const decision = await waitForToolDecision( toolCallId, options.timeout || STREAM_TIMEOUT_MS, @@ -372,6 +375,66 @@ export const subAgentHandlers: Record = { return } + // Integration tools (user-installed) require approval in interactive mode, + // same as top-level interrupt tools. + if (options.interactive === true && isIntegrationTool(toolName)) { + const decision = await waitForToolDecision( + toolCallId, + options.timeout || STREAM_TIMEOUT_MS, + options.abortSignal + ) + if (decision?.status === 'accepted' || decision?.status === 'success') { + await executeToolAndReport(toolCallId, context, execContext, options) + return + } + if (decision?.status === 'rejected' || decision?.status === 'error') { + toolCall.status = 'rejected' + toolCall.endTime = Date.now() + await markToolComplete( + toolCall.id, + toolCall.name, + 400, + decision.message || 'Tool execution rejected', + { skipped: true, reason: 'user_rejected' } + ) + markToolResultSeen(toolCall.id) + await options?.onEvent?.({ + type: 'tool_result', + toolCallId: toolCall.id, + data: { + id: toolCall.id, + name: toolCall.name, + success: false, + result: { skipped: true, reason: 'user_rejected' }, + }, + }) + return + } + if (decision?.status === 'background') { + toolCall.status = 'skipped' + toolCall.endTime = Date.now() + await markToolComplete( + toolCall.id, + toolCall.name, + 202, + decision.message || 'Tool execution moved to background', + { background: true } + ) + markToolResultSeen(toolCall.id) + await options?.onEvent?.({ + type: 'tool_result', + toolCallId: toolCall.id, + data: { + id: toolCall.id, + name: toolCall.name, + success: true, + result: { background: true }, + }, + }) + return + } + } + if (options.autoExecuteTools !== false) { await executeToolAndReport(toolCallId, context, execContext, options) } diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts index cb59feb4c..ed16a16e5 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts @@ -151,6 +151,19 @@ export function isToolAvailableOnSimSide(toolName: string): boolean { return !!getTool(resolvedToolName) } +/** + * Check whether a tool is a user-installed integration tool (e.g. Gmail, Slack). + * These tools exist in the tool registry but are NOT copilot server tools or + * known workflow manipulation tools. They should require user approval in + * interactive mode. + */ +export function isIntegrationTool(toolName: string): boolean { + if (SERVER_TOOLS.has(toolName)) return false + if (toolName in SIM_WORKFLOW_TOOL_HANDLERS) return false + const resolvedToolName = resolveToolId(toolName) + return !!getTool(resolvedToolName) +} + /** * Execute a tool server-side without calling internal routes. */ diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index 9ac5a9788..e1bb8fe7b 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -401,6 +401,7 @@ export function createUserToolSchema(toolConfig: ToolConfig): ToolSchema { } for (const [paramId, param] of Object.entries(toolConfig.params)) { + if (!param) continue const visibility = param.visibility ?? 'user-or-llm' if (visibility === 'hidden') { continue