This commit is contained in:
Siddharth Ganesan
2026-02-03 15:32:40 -08:00
parent 370adf362c
commit 1ddcebfe57
13 changed files with 630 additions and 1277 deletions

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { copilotChats, permissions, workflow } from '@sim/db/schema'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, asc, desc, eq, inArray, or } from 'drizzle-orm'
import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
@@ -21,6 +21,7 @@ import type { CopilotProviderConfig } from '@/lib/copilot/types'
import { env } from '@/lib/core/config/env'
import { CopilotFiles } from '@/lib/uploads'
import { createFileContent } from '@/lib/uploads/utils/file-utils'
import { resolveWorkflowIdForUser } from '@/lib/workflows/utils'
import { tools } from '@/tools/registry'
import { getLatestVersionTools, stripVersionSuffix } from '@/tools/utils'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
@@ -79,54 +80,6 @@ const ChatMessageSchema = z.object({
commands: z.array(z.string()).optional(),
})
async function resolveWorkflowId(
userId: string,
workflowId?: string,
workflowName?: string
): Promise<{ workflowId: string; workflowName?: string } | null> {
// If workflowId provided, use it directly
if (workflowId) {
return { workflowId }
}
// Get user's accessible workflows
const workspaceIds = await db
.select({ entityId: permissions.entityId })
.from(permissions)
.where(and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace')))
const workspaceIdList = workspaceIds.map((row) => row.entityId)
const workflowConditions = [eq(workflow.userId, userId)]
if (workspaceIdList.length > 0) {
workflowConditions.push(inArray(workflow.workspaceId, workspaceIdList))
}
const workflows = await db
.select()
.from(workflow)
.where(or(...workflowConditions))
.orderBy(asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id))
if (workflows.length === 0) {
return null
}
// If workflowName provided, find matching workflow
if (workflowName) {
const match = workflows.find(
(w) => String(w.name || '').trim().toLowerCase() === workflowName.toLowerCase()
)
if (match) {
return { workflowId: match.id, workflowName: match.name || undefined }
}
return null
}
// Default to first workflow
return { workflowId: workflows[0].id, workflowName: workflows[0].name || undefined }
}
/**
* POST /api/copilot/chat
* Send messages to sim agent and handle chat persistence
@@ -165,7 +118,11 @@ export async function POST(req: NextRequest) {
} = ChatMessageSchema.parse(body)
// Resolve workflowId - if not provided, use first workflow or find by name
const resolved = await resolveWorkflowId(authenticatedUserId, providedWorkflowId, workflowName)
const resolved = await resolveWorkflowIdForUser(
authenticatedUserId,
providedWorkflowId,
workflowName
)
if (!resolved) {
return createBadRequestResponse(
'No workflows found. Create a workflow first or provide a valid workflowId.'

View File

@@ -15,6 +15,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getCopilotModel } from '@/lib/copilot/config'
import { orchestrateSubagentStream } from '@/lib/copilot/orchestrator/subagent'
import { DIRECT_TOOL_DEFS, SUBAGENT_TOOL_DEFS } from '@/lib/copilot/tools/mcp/definitions'
import { executeToolServerSide, prepareExecutionContext } from '@/lib/copilot/orchestrator/tool-executor'
const logger = createLogger('CopilotMcpAPI')
@@ -167,468 +168,6 @@ Don't guess. Don't plan. Debug first to find the actual problem.
- copilot_debug: Diagnose errors (REQUIRES workflowId) - USE THIS FIRST for issues
`
/**
* Direct tools that execute immediately without LLM orchestration.
* These are fast database queries that don't need AI reasoning.
*/
const DIRECT_TOOL_DEFS: Array<{
name: string
description: string
inputSchema: { type: 'object'; properties?: Record<string, unknown>; required?: string[] }
toolId: string
}> = [
{
name: 'list_workflows',
toolId: 'list_user_workflows',
description: 'List all workflows the user has access to. Returns workflow IDs, names, and workspace info.',
inputSchema: {
type: 'object',
properties: {
workspaceId: {
type: 'string',
description: 'Optional workspace ID to filter workflows.',
},
folderId: {
type: 'string',
description: 'Optional folder ID to filter workflows.',
},
},
},
},
{
name: 'list_workspaces',
toolId: 'list_user_workspaces',
description: 'List all workspaces the user has access to. Returns workspace IDs, names, and roles.',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'list_folders',
toolId: 'list_folders',
description: 'List all folders in a workspace.',
inputSchema: {
type: 'object',
properties: {
workspaceId: {
type: 'string',
description: 'Workspace ID to list folders from.',
},
},
required: ['workspaceId'],
},
},
{
name: 'get_workflow',
toolId: 'get_workflow_from_name',
description: 'Get a workflow by name or ID. Returns the full workflow definition.',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Workflow name to search for.',
},
workflowId: {
type: 'string',
description: 'Workflow ID to retrieve directly.',
},
},
},
},
{
name: 'create_workflow',
toolId: 'create_workflow',
description: 'Create a new workflow. Returns the new workflow ID.',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Name for the new workflow.',
},
workspaceId: {
type: 'string',
description: 'Optional workspace ID. Uses default workspace if not provided.',
},
folderId: {
type: 'string',
description: 'Optional folder ID to place the workflow in.',
},
description: {
type: 'string',
description: 'Optional description for the workflow.',
},
},
required: ['name'],
},
},
{
name: 'create_folder',
toolId: 'create_folder',
description: 'Create a new folder in a workspace.',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Name for the new folder.',
},
workspaceId: {
type: 'string',
description: 'Optional workspace ID. Uses default workspace if not provided.',
},
parentId: {
type: 'string',
description: 'Optional parent folder ID for nested folders.',
},
},
required: ['name'],
},
},
]
const SUBAGENT_TOOL_DEFS: Array<{
name: string
description: string
inputSchema: { type: 'object'; properties?: Record<string, unknown>; required?: string[] }
agentId: string
}> = [
{
name: 'copilot_build',
agentId: 'build',
description: `Build a workflow end-to-end in a single step. This is the fast mode equivalent for headless/MCP usage.
USE THIS WHEN:
- Building a new workflow from scratch
- Modifying an existing workflow
- You want to gather information and build in one pass without separate plan→edit steps
WORKFLOW ID (REQUIRED):
- For NEW workflows: First call create_workflow to get a workflowId, then pass it here
- For EXISTING workflows: Always pass the workflowId parameter
CAN DO:
- Gather information about blocks, credentials, patterns
- Search documentation and patterns for best practices
- Add, modify, or remove blocks
- Configure block settings and connections
- Set environment variables and workflow variables
CANNOT DO:
- Run or test workflows (use copilot_test separately after deploying)
- Deploy workflows (use copilot_deploy separately)
WORKFLOW:
1. Call create_workflow to get a workflowId (for new workflows)
2. Call copilot_build with the request and workflowId
3. Build agent gathers info and builds in one pass
4. Call copilot_deploy to deploy the workflow
5. Optionally call copilot_test to verify it works`,
inputSchema: {
type: 'object',
properties: {
request: {
type: 'string',
description: 'What you want to build or modify in the workflow.',
},
workflowId: {
type: 'string',
description:
'REQUIRED. The workflow ID. For new workflows, call create_workflow first to get this.',
},
context: { type: 'object' },
},
required: ['request', 'workflowId'],
},
},
{
name: 'copilot_discovery',
agentId: 'discovery',
description: `Find workflows by their contents or functionality when the user doesn't know the exact name or ID.
USE THIS WHEN:
- User describes a workflow by what it does: "the one that sends emails", "my Slack notification workflow"
- User refers to workflow contents: "the workflow with the OpenAI block"
- User needs to search/match workflows by functionality or description
DO NOT USE (use direct tools instead):
- User knows the workflow name → use get_workflow
- User wants to list all workflows → use list_workflows
- User wants to list workspaces → use list_workspaces
- User wants to list folders → use list_folders`,
inputSchema: {
type: 'object',
properties: {
request: { type: 'string' },
workspaceId: { type: 'string' },
context: { type: 'object' },
},
required: ['request'],
},
},
{
name: 'copilot_plan',
agentId: 'plan',
description: `Plan workflow changes by gathering required information.
USE THIS WHEN:
- Building a new workflow
- Modifying an existing workflow
- You need to understand what blocks and integrations are available
- The workflow requires multiple blocks or connections
WORKFLOW ID (REQUIRED):
- For NEW workflows: First call create_workflow to get a workflowId, then pass it here
- For EXISTING workflows: Always pass the workflowId parameter
This tool gathers information about available blocks, credentials, and the current workflow state.
RETURNS: A plan object containing block configurations, connections, and technical details.
IMPORTANT: Pass the returned plan EXACTLY to copilot_edit - do not modify or summarize it.`,
inputSchema: {
type: 'object',
properties: {
request: { type: 'string', description: 'What you want to build or modify in the workflow.' },
workflowId: {
type: 'string',
description: 'REQUIRED. The workflow ID. For new workflows, call create_workflow first to get this.',
},
context: { type: 'object' },
},
required: ['request', 'workflowId'],
},
},
{
name: 'copilot_edit',
agentId: 'edit',
description: `Execute a workflow plan and apply edits.
USE THIS WHEN:
- You have a plan from copilot_plan that needs to be executed
- Building or modifying a workflow based on the plan
- Making changes to blocks, connections, or configurations
WORKFLOW ID (REQUIRED):
- You MUST provide the workflowId parameter
- For new workflows, get the workflowId from create_workflow first
PLAN (REQUIRED):
- Pass the EXACT plan object from copilot_plan in the context.plan field
- Do NOT modify, summarize, or interpret the plan - pass it verbatim
- The plan contains technical details the edit agent needs exactly as-is
IMPORTANT: After copilot_edit completes, you MUST call copilot_deploy before the workflow can be run or tested.`,
inputSchema: {
type: 'object',
properties: {
message: { type: 'string', description: 'Optional additional instructions for the edit.' },
workflowId: {
type: 'string',
description: 'REQUIRED. The workflow ID to edit. Get this from create_workflow for new workflows.',
},
plan: {
type: 'object',
description: 'The plan object from copilot_plan. Pass it EXACTLY as returned, do not modify.',
},
context: {
type: 'object',
description: 'Additional context. Put the plan in context.plan if not using the plan field directly.',
},
},
required: ['workflowId'],
},
},
{
name: 'copilot_debug',
agentId: 'debug',
description: `Diagnose errors or unexpected workflow behavior.
WORKFLOW ID (REQUIRED): Always provide the workflowId of the workflow to debug.`,
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'],
},
},
{
name: 'copilot_deploy',
agentId: 'deploy',
description: `Deploy or manage workflow deployments.
CRITICAL: You MUST deploy a workflow after building before it can be run or tested.
Workflows without an active deployment will fail with "no active deployment" error.
WORKFLOW ID (REQUIRED):
- Always provide the workflowId parameter
- This must match the workflow you built with copilot_edit
USE THIS:
- After copilot_edit completes to activate the workflow
- To update deployment settings
- To redeploy after making changes
DEPLOYMENT TYPES:
- "deploy as api" - REST API endpoint
- "deploy as chat" - Chat interface
- "deploy as mcp" - MCP server`,
inputSchema: {
type: 'object',
properties: {
request: {
type: 'string',
description: 'The deployment request, e.g. "deploy as api" or "deploy as chat"',
},
workflowId: {
type: 'string',
description: 'REQUIRED. The workflow ID to deploy.',
},
context: { type: 'object' },
},
required: ['request', 'workflowId'],
},
},
{
name: 'copilot_auth',
agentId: 'auth',
description: 'Handle OAuth connection flows.',
inputSchema: {
type: 'object',
properties: {
request: { type: 'string' },
context: { type: 'object' },
},
required: ['request'],
},
},
{
name: 'copilot_knowledge',
agentId: 'knowledge',
description: 'Create and manage knowledge bases.',
inputSchema: {
type: 'object',
properties: {
request: { type: 'string' },
context: { type: 'object' },
},
required: ['request'],
},
},
{
name: 'copilot_custom_tool',
agentId: 'custom_tool',
description: 'Create or manage custom tools.',
inputSchema: {
type: 'object',
properties: {
request: { type: 'string' },
context: { type: 'object' },
},
required: ['request'],
},
},
{
name: 'copilot_info',
agentId: 'info',
description: 'Inspect blocks, outputs, and workflow metadata.',
inputSchema: {
type: 'object',
properties: {
request: { type: 'string' },
workflowId: { type: 'string' },
context: { type: 'object' },
},
required: ['request'],
},
},
{
name: 'copilot_workflow',
agentId: 'workflow',
description: 'Manage workflow environment and configuration.',
inputSchema: {
type: 'object',
properties: {
request: { type: 'string' },
workflowId: { type: 'string' },
context: { type: 'object' },
},
required: ['request'],
},
},
{
name: 'copilot_research',
agentId: 'research',
description: 'Research external APIs and documentation.',
inputSchema: {
type: 'object',
properties: {
request: { type: 'string' },
context: { type: 'object' },
},
required: ['request'],
},
},
{
name: 'copilot_tour',
agentId: 'tour',
description: 'Explain platform features and usage.',
inputSchema: {
type: 'object',
properties: {
request: { type: 'string' },
context: { type: 'object' },
},
required: ['request'],
},
},
{
name: 'copilot_test',
agentId: 'test',
description: `Run workflows and verify outputs.
PREREQUISITE: The workflow MUST be deployed first using copilot_deploy.
Undeployed workflows will fail with "no active deployment" error.
WORKFLOW ID (REQUIRED):
- Always provide the workflowId parameter
USE THIS:
- After deploying to verify the workflow works correctly
- To test with sample inputs
- To validate workflow behavior before sharing with user`,
inputSchema: {
type: 'object',
properties: {
request: { type: 'string' },
workflowId: {
type: 'string',
description: 'REQUIRED. The workflow ID to test.',
},
context: { type: 'object' },
},
required: ['request', 'workflowId'],
},
},
{
name: 'copilot_superagent',
agentId: 'superagent',
description: 'Execute direct external actions (email, Slack, etc.).',
inputSchema: {
type: 'object',
properties: {
request: { type: 'string' },
context: { type: 'object' },
},
required: ['request'],
},
},
]
function createResponse(id: RequestId, result: unknown): JSONRPCResponse {
return {
jsonrpc: '2.0',

View File

@@ -1,7 +1,4 @@
import { db } from '@sim/db'
import { permissions, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, asc, eq, inArray, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { authenticateV1Request } from '@/app/api/v1/auth'
@@ -9,6 +6,7 @@ import { getCopilotModel } from '@/lib/copilot/config'
import { SIM_AGENT_VERSION } from '@/lib/copilot/constants'
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { resolveWorkflowIdForUser } from '@/lib/workflows/utils'
const logger = createLogger('CopilotHeadlessAPI')
@@ -23,54 +21,6 @@ const RequestSchema = z.object({
timeout: z.number().optional().default(300000),
})
async function resolveWorkflowId(
userId: string,
workflowId?: string,
workflowName?: string
): Promise<{ workflowId: string; workflowName?: string } | null> {
// If workflowId provided, use it directly
if (workflowId) {
return { workflowId }
}
// Get user's accessible workflows
const workspaceIds = await db
.select({ entityId: permissions.entityId })
.from(permissions)
.where(and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace')))
const workspaceIdList = workspaceIds.map((row) => row.entityId)
const workflowConditions = [eq(workflow.userId, userId)]
if (workspaceIdList.length > 0) {
workflowConditions.push(inArray(workflow.workspaceId, workspaceIdList))
}
const workflows = await db
.select()
.from(workflow)
.where(or(...workflowConditions))
.orderBy(asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id))
if (workflows.length === 0) {
return null
}
// If workflowName provided, find matching workflow
if (workflowName) {
const match = workflows.find(
(w) => String(w.name || '').trim().toLowerCase() === workflowName.toLowerCase()
)
if (match) {
return { workflowId: match.id, workflowName: match.name || undefined }
}
return null
}
// Default to first workflow
return { workflowId: workflows[0].id, workflowName: workflows[0].name || undefined }
}
/**
* POST /api/v1/copilot/chat
* Headless copilot endpoint for server-side orchestration.
@@ -93,7 +43,7 @@ export async function POST(req: NextRequest) {
const selectedModel = parsed.model || defaults.model
// Resolve workflow ID
const resolved = await resolveWorkflowId(auth.userId, parsed.workflowId, parsed.workflowName)
const resolved = await resolveWorkflowIdForUser(auth.userId, parsed.workflowId, parsed.workflowName)
if (!resolved) {
return NextResponse.json(
{ success: false, error: 'No workflows found. Create a workflow first or provide a valid workflowId.' },

View File

@@ -26,7 +26,6 @@ import { getBlock } from '@/blocks/registry'
import type { CopilotToolCall } from '@/stores/panel'
import { useCopilotStore } from '@/stores/panel'
import { CLASS_TOOL_METADATA } from '@/stores/panel/copilot/store'
import { COPILOT_SERVER_ORCHESTRATED } from '@/lib/copilot/orchestrator/config'
import type { SubAgentContentBlock } from '@/stores/panel/copilot/types'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -1263,7 +1262,7 @@ function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean {
const toolCallLogger = createLogger('CopilotToolCall')
async function sendToolDecision(toolCallId: string, status: 'accepted' | 'rejected') {
async function sendToolDecision(toolCallId: string, status: 'accepted' | 'rejected' | 'background') {
try {
await fetch('/api/copilot/confirm', {
method: 'POST',
@@ -1285,105 +1284,15 @@ async function handleRun(
onStateChange?: any,
editedParams?: any
) {
if (COPILOT_SERVER_ORCHESTRATED) {
setToolCallState(toolCall, 'executing')
onStateChange?.('executing')
await sendToolDecision(toolCall.id, 'accepted')
return
}
const instance = getClientTool(toolCall.id)
if (!instance && isIntegrationTool(toolCall.name)) {
onStateChange?.('executing')
try {
await useCopilotStore.getState().executeIntegrationTool(toolCall.id)
} catch (e) {
setToolCallState(toolCall, 'error', { error: e instanceof Error ? e.message : String(e) })
onStateChange?.('error')
try {
await fetch('/api/copilot/tools/mark-complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: toolCall.id,
name: toolCall.name,
status: 500,
message: e instanceof Error ? e.message : 'Tool execution failed',
data: { error: e instanceof Error ? e.message : String(e) },
}),
})
} catch {
console.error('[handleRun] Failed to notify backend of tool error:', toolCall.id)
}
}
return
}
if (!instance) return
try {
const mergedParams =
editedParams ||
(toolCall as any).params ||
(toolCall as any).parameters ||
(toolCall as any).input ||
{}
await instance.handleAccept?.(mergedParams)
onStateChange?.('executing')
} catch (e) {
setToolCallState(toolCall, 'error', { error: e instanceof Error ? e.message : String(e) })
}
setToolCallState(toolCall, 'executing', editedParams ? { params: editedParams } : undefined)
onStateChange?.('executing')
await sendToolDecision(toolCall.id, 'accepted')
}
async function handleSkip(toolCall: CopilotToolCall, setToolCallState: any, onStateChange?: any) {
if (COPILOT_SERVER_ORCHESTRATED) {
setToolCallState(toolCall, 'rejected')
onStateChange?.('rejected')
await sendToolDecision(toolCall.id, 'rejected')
return
}
const instance = getClientTool(toolCall.id)
if (!instance && isIntegrationTool(toolCall.name)) {
setToolCallState(toolCall, 'rejected')
onStateChange?.('rejected')
let notified = false
for (let attempt = 0; attempt < 3 && !notified; attempt++) {
try {
const res = await fetch('/api/copilot/tools/mark-complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: toolCall.id,
name: toolCall.name,
status: 400,
message: 'Tool execution skipped by user',
data: { skipped: true, reason: 'user_skipped' },
}),
})
if (res.ok) {
notified = true
}
} catch (e) {
if (attempt < 2) {
await new Promise((resolve) => setTimeout(resolve, 500))
}
}
}
if (!notified) {
console.error('[handleSkip] Failed to notify backend after 3 attempts:', toolCall.id)
}
return
}
if (instance) {
try {
await instance.handleReject?.()
} catch {}
}
setToolCallState(toolCall, 'rejected')
onStateChange?.('rejected')
await sendToolDecision(toolCall.id, 'rejected')
}
function getDisplayName(toolCall: CopilotToolCall): string {
@@ -1543,7 +1452,7 @@ export function ToolCall({
// Check if this integration tool is auto-allowed
// Subscribe to autoAllowedTools so we re-render when it changes
const autoAllowedTools = useCopilotStore((s) => s.autoAllowedTools)
const { removeAutoAllowedTool } = useCopilotStore()
const { removeAutoAllowedTool, setToolCallState } = useCopilotStore()
const isAutoAllowed = isIntegrationTool(toolCall.name) && autoAllowedTools.includes(toolCall.name)
// Update edited params when toolCall params change (deep comparison to avoid resetting user edits on ref change)
@@ -2245,16 +2154,9 @@ export function ToolCall({
<div className='mt-[10px]'>
<Button
onClick={async () => {
try {
const instance = getClientTool(toolCall.id)
instance?.setState?.((ClientToolCallState as any).background)
await instance?.markToolComplete?.(
200,
'The user has chosen to move the workflow execution to the background. Check back with them later to know when the workflow execution is complete'
)
forceUpdate({})
onStateChange?.('background')
} catch {}
setToolCallState(toolCall, ClientToolCallState.background)
onStateChange?.('background')
await sendToolDecision(toolCall.id, 'background')
}}
variant='tertiary'
title='Move to Background'
@@ -2266,21 +2168,9 @@ export function ToolCall({
<div className='mt-[10px]'>
<Button
onClick={async () => {
try {
const instance = getClientTool(toolCall.id)
const elapsedSeconds = instance?.getElapsedSeconds?.() || 0
instance?.setState?.((ClientToolCallState as any).background, {
result: { _elapsedSeconds: elapsedSeconds },
})
const { updateToolCallParams } = useCopilotStore.getState()
updateToolCallParams?.(toolCall.id, { _elapsedSeconds: Math.round(elapsedSeconds) })
await instance?.markToolComplete?.(
200,
`User woke you up after ${Math.round(elapsedSeconds)} seconds`
)
forceUpdate({})
onStateChange?.('background')
} catch {}
setToolCallState(toolCall, ClientToolCallState.background)
onStateChange?.('background')
await sendToolDecision(toolCall.id, 'background')
}}
variant='tertiary'
title='Wake'

View File

@@ -1,8 +1,3 @@
/**
* Feature flag for server-side copilot orchestration.
*/
export const COPILOT_SERVER_ORCHESTRATED = true
export const INTERRUPT_TOOL_NAMES = [
'set_global_workflow_variables',
'run_workflow',

View File

@@ -22,7 +22,11 @@ import { env } from '@/lib/core/config/env'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace'
import { mcpService } from '@/lib/mcp/service'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import {
extractWorkflowNames,
formatNormalizedWorkflowForCopilot,
normalizeWorkflowName,
} from '@/lib/copilot/tools/shared/workflow-utils'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import {
deployWorkflow,
@@ -373,6 +377,35 @@ async function ensureWorkspaceAccess(
}
}
async function getAccessibleWorkflowsForUser(
userId: string,
options?: { workspaceId?: string; folderId?: string }
) {
const workspaceIds = await db
.select({ entityId: permissions.entityId })
.from(permissions)
.where(and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace')))
const workspaceIdList = workspaceIds.map((row) => row.entityId)
const workflowConditions = [eq(workflow.userId, userId)]
if (workspaceIdList.length > 0) {
workflowConditions.push(inArray(workflow.workspaceId, workspaceIdList))
}
if (options?.workspaceId) {
workflowConditions.push(eq(workflow.workspaceId, options.workspaceId))
}
if (options?.folderId) {
workflowConditions.push(eq(workflow.folderId, options.folderId))
}
return db
.select()
.from(workflow)
.where(or(...workflowConditions))
.orderBy(asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id))
}
async function executeGetUserWorkflow(
params: Record<string, any>,
context: ExecutionContext
@@ -389,19 +422,11 @@ async function executeGetUserWorkflow(
)
const normalized = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalized) {
const userWorkflow = formatNormalizedWorkflowForCopilot(normalized)
if (!userWorkflow) {
return { success: false, error: 'Workflow has no normalized data' }
}
const workflowState = {
blocks: normalized.blocks || {},
edges: normalized.edges || [],
loops: normalized.loops || {},
parallels: normalized.parallels || {},
}
const sanitized = sanitizeForCopilot(workflowState)
const userWorkflow = JSON.stringify(sanitized, null, 2)
// Return workflow ID so copilot can use it for subsequent tool calls
return {
success: true,
@@ -427,43 +452,20 @@ async function executeGetWorkflowFromName(
return { success: false, error: 'workflow_name is required' }
}
const workspaceIds = await db
.select({ entityId: permissions.entityId })
.from(permissions)
.where(and(eq(permissions.userId, context.userId), eq(permissions.entityType, 'workspace')))
const workflows = await getAccessibleWorkflowsForUser(context.userId)
const workspaceIdList = workspaceIds.map((row) => row.entityId)
const workflowConditions = [eq(workflow.userId, context.userId)]
if (workspaceIdList.length > 0) {
workflowConditions.push(inArray(workflow.workspaceId, workspaceIdList))
}
const workflows = await db
.select()
.from(workflow)
.where(or(...workflowConditions))
const match = workflows.find(
(w) => String(w.name || '').trim().toLowerCase() === workflowName.toLowerCase()
)
const targetName = normalizeWorkflowName(workflowName)
const match = workflows.find((w) => normalizeWorkflowName(w.name) === targetName)
if (!match) {
return { success: false, error: `Workflow not found: ${workflowName}` }
}
const normalized = await loadWorkflowFromNormalizedTables(match.id)
if (!normalized) {
const userWorkflow = formatNormalizedWorkflowForCopilot(normalized)
if (!userWorkflow) {
return { success: false, error: 'Workflow has no normalized data' }
}
const workflowState = {
blocks: normalized.blocks || {},
edges: normalized.edges || [],
loops: normalized.loops || {},
parallels: normalized.parallels || {},
}
const sanitized = sanitizeForCopilot(workflowState)
const userWorkflow = JSON.stringify(sanitized, null, 2)
// Return workflow ID and workspaceId so copilot can use them for subsequent tool calls
return {
success: true,
@@ -487,33 +489,10 @@ async function executeListUserWorkflows(
const workspaceId = params?.workspaceId as string | undefined
const folderId = params?.folderId as string | undefined
const workspaceIds = await db
.select({ entityId: permissions.entityId })
.from(permissions)
.where(and(eq(permissions.userId, context.userId), eq(permissions.entityType, 'workspace')))
const workspaceIdList = workspaceIds.map((row) => row.entityId)
const workflowConditions = [eq(workflow.userId, context.userId)]
if (workspaceIdList.length > 0) {
workflowConditions.push(inArray(workflow.workspaceId, workspaceIdList))
}
if (workspaceId) {
workflowConditions.push(eq(workflow.workspaceId, workspaceId))
}
if (folderId) {
workflowConditions.push(eq(workflow.folderId, folderId))
}
const workflows = await db
.select()
.from(workflow)
.where(or(...workflowConditions))
.orderBy(asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id))
const workflows = await getAccessibleWorkflowsForUser(context.userId, { workspaceId, folderId })
// Return both names (for backward compatibility) and full workflow info with IDs
const names = workflows
.map((w) => (typeof w.name === 'string' ? w.name : null))
.filter((n): n is string => Boolean(n))
const names = extractWorkflowNames(workflows)
const workflowList = workflows.map((w) => ({
workflowId: w.id,

View File

@@ -5,7 +5,10 @@ import {
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import {
formatWorkflowStateForCopilot,
normalizeWorkflowName,
} from '@/lib/copilot/tools/shared/workflow-utils'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('GetWorkflowFromNameClientTool')
@@ -67,11 +70,9 @@ export class GetWorkflowFromNameClientTool extends BaseClientTool {
// Try to find by name from registry first to get ID
const registry = useWorkflowRegistry.getState()
const targetName = normalizeWorkflowName(workflowName)
const match = Object.values((registry as any).workflows || {}).find(
(w: any) =>
String(w?.name || '')
.trim()
.toLowerCase() === workflowName.toLowerCase()
(w: any) => normalizeWorkflowName(w?.name) === targetName
) as any
if (!match?.id) {
@@ -98,15 +99,12 @@ export class GetWorkflowFromNameClientTool extends BaseClientTool {
}
// Convert state to the same string format as get_user_workflow
const workflowState = {
const userWorkflow = formatWorkflowStateForCopilot({
blocks: wf.state.blocks || {},
edges: wf.state.edges || [],
loops: wf.state.loops || {},
parallels: wf.state.parallels || {},
}
// Sanitize workflow state for copilot (remove UI-specific data)
const sanitizedState = sanitizeForCopilot(workflowState)
const userWorkflow = JSON.stringify(sanitizedState, null, 2)
})
await this.markToolComplete(200, `Retrieved workflow ${workflowName}`, { userWorkflow })
this.setState(ClientToolCallState.success)

View File

@@ -5,6 +5,7 @@ import {
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { extractWorkflowNames } from '@/lib/copilot/tools/shared/workflow-utils'
const logger = createLogger('ListUserWorkflowsClientTool')
@@ -41,9 +42,7 @@ export class ListUserWorkflowsClientTool extends BaseClientTool {
const json = await res.json()
const workflows = Array.isArray(json?.data) ? json.data : []
const names = workflows
.map((w: any) => (typeof w?.name === 'string' ? w.name : null))
.filter((n: string | null) => !!n)
const names = extractWorkflowNames(workflows)
logger.info('Found workflows', { count: names.length })

View File

@@ -0,0 +1,466 @@
export type DirectToolDef = {
name: string
description: string
inputSchema: { type: 'object'; properties?: Record<string, unknown>; required?: string[] }
toolId: string
}
export type SubagentToolDef = {
name: string
description: string
inputSchema: { type: 'object'; properties?: Record<string, unknown>; required?: string[] }
agentId: string
}
/**
* Direct tools that execute immediately without LLM orchestration.
* These are fast database queries that don't need AI reasoning.
*/
export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
{
name: 'list_workflows',
toolId: 'list_user_workflows',
description: 'List all workflows the user has access to. Returns workflow IDs, names, and workspace info.',
inputSchema: {
type: 'object',
properties: {
workspaceId: {
type: 'string',
description: 'Optional workspace ID to filter workflows.',
},
folderId: {
type: 'string',
description: 'Optional folder ID to filter workflows.',
},
},
},
},
{
name: 'list_workspaces',
toolId: 'list_user_workspaces',
description: 'List all workspaces the user has access to. Returns workspace IDs, names, and roles.',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'list_folders',
toolId: 'list_folders',
description: 'List all folders in a workspace.',
inputSchema: {
type: 'object',
properties: {
workspaceId: {
type: 'string',
description: 'Workspace ID to list folders from.',
},
},
required: ['workspaceId'],
},
},
{
name: 'get_workflow',
toolId: 'get_workflow_from_name',
description: 'Get a workflow by name or ID. Returns the full workflow definition.',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Workflow name to search for.',
},
workflowId: {
type: 'string',
description: 'Workflow ID to retrieve directly.',
},
},
},
},
{
name: 'create_workflow',
toolId: 'create_workflow',
description: 'Create a new workflow. Returns the new workflow ID.',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Name for the new workflow.',
},
workspaceId: {
type: 'string',
description: 'Optional workspace ID. Uses default workspace if not provided.',
},
folderId: {
type: 'string',
description: 'Optional folder ID to place the workflow in.',
},
description: {
type: 'string',
description: 'Optional description for the workflow.',
},
},
required: ['name'],
},
},
{
name: 'create_folder',
toolId: 'create_folder',
description: 'Create a new folder in a workspace.',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Name for the new folder.',
},
workspaceId: {
type: 'string',
description: 'Optional workspace ID. Uses default workspace if not provided.',
},
parentId: {
type: 'string',
description: 'Optional parent folder ID for nested folders.',
},
},
required: ['name'],
},
},
]
export const SUBAGENT_TOOL_DEFS: SubagentToolDef[] = [
{
name: 'copilot_build',
agentId: 'build',
description: `Build a workflow end-to-end in a single step. This is the fast mode equivalent for headless/MCP usage.
USE THIS WHEN:
- Building a new workflow from scratch
- Modifying an existing workflow
- You want to gather information and build in one pass without separate plan→edit steps
WORKFLOW ID (REQUIRED):
- For NEW workflows: First call create_workflow to get a workflowId, then pass it here
- For EXISTING workflows: Always pass the workflowId parameter
CAN DO:
- Gather information about blocks, credentials, patterns
- Search documentation and patterns for best practices
- Add, modify, or remove blocks
- Configure block settings and connections
- Set environment variables and workflow variables
CANNOT DO:
- Run or test workflows (use copilot_test separately after deploying)
- Deploy workflows (use copilot_deploy separately)
WORKFLOW:
1. Call create_workflow to get a workflowId (for new workflows)
2. Call copilot_build with the request and workflowId
3. Build agent gathers info and builds in one pass
4. Call copilot_deploy to deploy the workflow
5. Optionally call copilot_test to verify it works`,
inputSchema: {
type: 'object',
properties: {
request: {
type: 'string',
description: 'What you want to build or modify in the workflow.',
},
workflowId: {
type: 'string',
description:
'REQUIRED. The workflow ID. For new workflows, call create_workflow first to get this.',
},
context: { type: 'object' },
},
required: ['request', 'workflowId'],
},
},
{
name: 'copilot_discovery',
agentId: 'discovery',
description: `Find workflows by their contents or functionality when the user doesn't know the exact name or ID.
USE THIS WHEN:
- User describes a workflow by what it does: "the one that sends emails", "my Slack notification workflow"
- User refers to workflow contents: "the workflow with the OpenAI block"
- User needs to search/match workflows by functionality or description
DO NOT USE (use direct tools instead):
- User knows the workflow name → use get_workflow
- User wants to list all workflows → use list_workflows
- User wants to list workspaces → use list_workspaces
- User wants to list folders → use list_folders`,
inputSchema: {
type: 'object',
properties: {
request: { type: 'string' },
workspaceId: { type: 'string' },
context: { type: 'object' },
},
required: ['request'],
},
},
{
name: 'copilot_plan',
agentId: 'plan',
description: `Plan workflow changes by gathering required information.
USE THIS WHEN:
- Building a new workflow
- Modifying an existing workflow
- You need to understand what blocks and integrations are available
- The workflow requires multiple blocks or connections
WORKFLOW ID (REQUIRED):
- For NEW workflows: First call create_workflow to get a workflowId, then pass it here
- For EXISTING workflows: Always pass the workflowId parameter
This tool gathers information about available blocks, credentials, and the current workflow state.
RETURNS: A plan object containing block configurations, connections, and technical details.
IMPORTANT: Pass the returned plan EXACTLY to copilot_edit - do not modify or summarize it.`,
inputSchema: {
type: 'object',
properties: {
request: { type: 'string', description: 'What you want to build or modify in the workflow.' },
workflowId: {
type: 'string',
description: 'REQUIRED. The workflow ID. For new workflows, call create_workflow first to get this.',
},
context: { type: 'object' },
},
required: ['request', 'workflowId'],
},
},
{
name: 'copilot_edit',
agentId: 'edit',
description: `Execute a workflow plan and apply edits.
USE THIS WHEN:
- You have a plan from copilot_plan that needs to be executed
- Building or modifying a workflow based on the plan
- Making changes to blocks, connections, or configurations
WORKFLOW ID (REQUIRED):
- You MUST provide the workflowId parameter
- For new workflows, get the workflowId from create_workflow first
PLAN (REQUIRED):
- Pass the EXACT plan object from copilot_plan in the context.plan field
- Do NOT modify, summarize, or interpret the plan - pass it verbatim
- The plan contains technical details the edit agent needs exactly as-is
IMPORTANT: After copilot_edit completes, you MUST call copilot_deploy before the workflow can be run or tested.`,
inputSchema: {
type: 'object',
properties: {
message: { type: 'string', description: 'Optional additional instructions for the edit.' },
workflowId: {
type: 'string',
description: 'REQUIRED. The workflow ID to edit. Get this from create_workflow for new workflows.',
},
plan: {
type: 'object',
description: 'The plan object from copilot_plan. Pass it EXACTLY as returned, do not modify.',
},
context: {
type: 'object',
description: 'Additional context. Put the plan in context.plan if not using the plan field directly.',
},
},
required: ['workflowId'],
},
},
{
name: 'copilot_debug',
agentId: 'debug',
description: `Diagnose errors or unexpected workflow behavior.
WORKFLOW ID (REQUIRED): Always provide the workflowId of the workflow to debug.`,
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'],
},
},
{
name: 'copilot_deploy',
agentId: 'deploy',
description: `Deploy or manage workflow deployments.
CRITICAL: You MUST deploy a workflow after building before it can be run or tested.
Workflows without an active deployment will fail with "no active deployment" error.
WORKFLOW ID (REQUIRED):
- Always provide the workflowId parameter
- This must match the workflow you built with copilot_edit
USE THIS:
- After copilot_edit completes to activate the workflow
- To update deployment settings
- To redeploy after making changes
DEPLOYMENT TYPES:
- "deploy as api" - REST API endpoint
- "deploy as chat" - Chat interface
- "deploy as mcp" - MCP server`,
inputSchema: {
type: 'object',
properties: {
request: {
type: 'string',
description: 'The deployment request, e.g. "deploy as api" or "deploy as chat"',
},
workflowId: {
type: 'string',
description: 'REQUIRED. The workflow ID to deploy.',
},
context: { type: 'object' },
},
required: ['request', 'workflowId'],
},
},
{
name: 'copilot_auth',
agentId: 'auth',
description: 'Handle OAuth connection flows.',
inputSchema: {
type: 'object',
properties: {
request: { type: 'string' },
context: { type: 'object' },
},
required: ['request'],
},
},
{
name: 'copilot_knowledge',
agentId: 'knowledge',
description: 'Create and manage knowledge bases.',
inputSchema: {
type: 'object',
properties: {
request: { type: 'string' },
context: { type: 'object' },
},
required: ['request'],
},
},
{
name: 'copilot_custom_tool',
agentId: 'custom_tool',
description: 'Create or manage custom tools.',
inputSchema: {
type: 'object',
properties: {
request: { type: 'string' },
context: { type: 'object' },
},
required: ['request'],
},
},
{
name: 'copilot_info',
agentId: 'info',
description: 'Inspect blocks, outputs, and workflow metadata.',
inputSchema: {
type: 'object',
properties: {
request: { type: 'string' },
workflowId: { type: 'string' },
context: { type: 'object' },
},
required: ['request'],
},
},
{
name: 'copilot_workflow',
agentId: 'workflow',
description: 'Manage workflow environment and configuration.',
inputSchema: {
type: 'object',
properties: {
request: { type: 'string' },
workflowId: { type: 'string' },
context: { type: 'object' },
},
required: ['request'],
},
},
{
name: 'copilot_research',
agentId: 'research',
description: 'Research external APIs and documentation.',
inputSchema: {
type: 'object',
properties: {
request: { type: 'string' },
context: { type: 'object' },
},
required: ['request'],
},
},
{
name: 'copilot_tour',
agentId: 'tour',
description: 'Explain platform features and usage.',
inputSchema: {
type: 'object',
properties: {
request: { type: 'string' },
context: { type: 'object' },
},
required: ['request'],
},
},
{
name: 'copilot_test',
agentId: 'test',
description: `Run workflows and verify outputs.
PREREQUISITE: The workflow MUST be deployed first using copilot_deploy.
Undeployed workflows will fail with "no active deployment" error.
WORKFLOW ID (REQUIRED):
- Always provide the workflowId parameter
USE THIS:
- After deploying to verify the workflow works correctly
- To test with sample inputs
- To validate workflow behavior before sharing with user`,
inputSchema: {
type: 'object',
properties: {
request: { type: 'string' },
workflowId: {
type: 'string',
description: 'REQUIRED. The workflow ID to test.',
},
context: { type: 'object' },
},
required: ['request', 'workflowId'],
},
},
{
name: 'copilot_superagent',
agentId: 'superagent',
description: 'Execute direct external actions (email, Slack, etc.).',
inputSchema: {
type: 'object',
properties: {
request: { type: 'string' },
context: { type: 'object' },
},
required: ['request'],
},
},
]

View File

@@ -0,0 +1,37 @@
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
type CopilotWorkflowState = {
blocks?: Record<string, any>
edges?: any[]
loops?: Record<string, any>
parallels?: Record<string, any>
}
export function formatWorkflowStateForCopilot(state: CopilotWorkflowState): string {
const workflowState = {
blocks: state.blocks || {},
edges: state.edges || [],
loops: state.loops || {},
parallels: state.parallels || {},
}
const sanitized = sanitizeForCopilot(workflowState)
return JSON.stringify(sanitized, null, 2)
}
export function formatNormalizedWorkflowForCopilot(
normalized: CopilotWorkflowState | null | undefined
): string | null {
if (!normalized) return null
return formatWorkflowStateForCopilot(normalized)
}
export function normalizeWorkflowName(name?: string | null): string {
return String(name || '').trim().toLowerCase()
}
export function extractWorkflowNames(workflows: Array<{ name?: string | null }>): string[] {
return workflows
.map((workflow) => (typeof workflow?.name === 'string' ? workflow.name : null))
.filter((name): name is string => Boolean(name))
}

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { permissions, userStats, workflow as workflowTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { and, asc, eq, inArray, or } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { getWorkspaceWithOwner, type PermissionType } from '@/lib/workspaces/permissions/utils'
@@ -15,6 +15,50 @@ export async function getWorkflowById(id: string) {
return rows[0]
}
export async function resolveWorkflowIdForUser(
userId: string,
workflowId?: string,
workflowName?: string
): Promise<{ workflowId: string; workflowName?: string } | null> {
if (workflowId) {
return { workflowId }
}
const workspaceIds = await db
.select({ entityId: permissions.entityId })
.from(permissions)
.where(and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace')))
const workspaceIdList = workspaceIds.map((row) => row.entityId)
const workflowConditions = [eq(workflowTable.userId, userId)]
if (workspaceIdList.length > 0) {
workflowConditions.push(inArray(workflowTable.workspaceId, workspaceIdList))
}
const workflows = await db
.select()
.from(workflowTable)
.where(or(...workflowConditions))
.orderBy(asc(workflowTable.sortOrder), asc(workflowTable.createdAt), asc(workflowTable.id))
if (workflows.length === 0) {
return null
}
if (workflowName) {
const match = workflows.find(
(w) => String(w.name || '').trim().toLowerCase() === workflowName.toLowerCase()
)
if (match) {
return { workflowId: match.id, workflowName: match.name || undefined }
}
return null
}
return { workflowId: workflows[0].id, workflowName: workflows[0].name || undefined }
}
type WorkflowRecord = ReturnType<typeof getWorkflowById> extends Promise<infer R>
? NonNullable<R>
: never

View File

@@ -53,8 +53,7 @@ import { SleepClientTool } from '@/lib/copilot/tools/client/other/sleep'
import { TestClientTool } from '@/lib/copilot/tools/client/other/test'
import { TourClientTool } from '@/lib/copilot/tools/client/other/tour'
import { WorkflowClientTool } from '@/lib/copilot/tools/client/other/workflow'
import { createExecutionContext, getTool } from '@/lib/copilot/tools/client/registry'
import { COPILOT_SERVER_ORCHESTRATED } from '@/lib/copilot/orchestrator/config'
import { getTool } from '@/lib/copilot/tools/client/registry'
import { GetCredentialsClientTool } from '@/lib/copilot/tools/client/user/get-credentials'
import { SetEnvironmentVariablesClientTool } from '@/lib/copilot/tools/client/user/set-environment-variables'
import { CheckDeploymentStatusClientTool } from '@/lib/copilot/tools/client/workflow/check-deployment-status'
@@ -1200,7 +1199,7 @@ const sseHandlers: Record<string, SSEHandler> = {
} catch {}
}
if (COPILOT_SERVER_ORCHESTRATED && current.name === 'edit_workflow') {
if (current.name === 'edit_workflow') {
try {
const resultPayload =
data?.result || data?.data?.result || data?.data?.data || data?.data || {}
@@ -1375,229 +1374,7 @@ const sseHandlers: Record<string, SSEHandler> = {
return
}
if (COPILOT_SERVER_ORCHESTRATED) {
return
}
// Prefer interface-based registry to determine interrupt and execute
try {
const def = name ? getTool(name) : undefined
if (def) {
const hasInterrupt =
typeof def.hasInterrupt === 'function'
? !!def.hasInterrupt(args || {})
: !!def.hasInterrupt
// Check if tool is auto-allowed - if so, execute even if it has an interrupt
const { autoAllowedTools } = get()
const isAutoAllowed = name ? autoAllowedTools.includes(name) : false
if ((!hasInterrupt || isAutoAllowed) && typeof def.execute === 'function') {
if (isAutoAllowed && hasInterrupt) {
logger.info('[toolCallsById] Auto-executing tool with interrupt (auto-allowed)', {
id,
name,
})
}
const ctx = createExecutionContext({ toolCallId: id, toolName: name || 'unknown_tool' })
// Defer executing transition by a tick to let pending render
setTimeout(() => {
// Guard against duplicate execution - check if already executing or terminal
const currentState = get().toolCallsById[id]?.state
if (currentState === ClientToolCallState.executing || isTerminalState(currentState)) {
return
}
const executingMap = { ...get().toolCallsById }
executingMap[id] = {
...executingMap[id],
state: ClientToolCallState.executing,
display: resolveToolDisplay(name, ClientToolCallState.executing, id, args),
}
set({ toolCallsById: executingMap })
logger.info('[toolCallsById] pending → executing (registry)', { id, name })
// Update inline content block to executing
for (let i = 0; i < context.contentBlocks.length; i++) {
const b = context.contentBlocks[i] as any
if (b.type === 'tool_call' && b.toolCall?.id === id) {
context.contentBlocks[i] = {
...b,
toolCall: { ...b.toolCall, state: ClientToolCallState.executing },
}
break
}
}
updateStreamingMessage(set, context)
Promise.resolve()
.then(async () => {
const result = await def.execute(ctx, args || {})
const success =
result && typeof result.status === 'number'
? result.status >= 200 && result.status < 300
: true
const completeMap = { ...get().toolCallsById }
// Do not override terminal review/rejected
if (
isRejectedState(completeMap[id]?.state) ||
isReviewState(completeMap[id]?.state) ||
isBackgroundState(completeMap[id]?.state)
) {
return
}
completeMap[id] = {
...completeMap[id],
state: success ? ClientToolCallState.success : ClientToolCallState.error,
display: resolveToolDisplay(
name,
success ? ClientToolCallState.success : ClientToolCallState.error,
id,
args
),
}
set({ toolCallsById: completeMap })
logger.info(
`[toolCallsById] executing → ${success ? 'success' : 'error'} (registry)`,
{ id, name }
)
// Notify backend tool mark-complete endpoint
try {
await fetch('/api/copilot/tools/mark-complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id,
name: name || 'unknown_tool',
status:
typeof result?.status === 'number' ? result.status : success ? 200 : 500,
message: result?.message,
data: result?.data,
}),
})
} catch {}
})
.catch((e) => {
const errorMap = { ...get().toolCallsById }
// Do not override terminal review/rejected
if (
isRejectedState(errorMap[id]?.state) ||
isReviewState(errorMap[id]?.state) ||
isBackgroundState(errorMap[id]?.state)
) {
return
}
errorMap[id] = {
...errorMap[id],
state: ClientToolCallState.error,
display: resolveToolDisplay(name, ClientToolCallState.error, id, args),
}
set({ toolCallsById: errorMap })
logger.error('Registry auto-execute tool failed', { id, name, error: e })
})
}, 0)
return
}
}
} catch (e) {
logger.warn('tool_call registry auto-exec check failed', { id, name, error: e })
}
// Class-based auto-exec for non-interrupt tools or auto-allowed tools
try {
const inst = getClientTool(id) as any
const hasInterrupt = !!inst?.getInterruptDisplays?.()
// Check if tool is auto-allowed - if so, execute even if it has an interrupt
const { autoAllowedTools: classAutoAllowed } = get()
const isClassAutoAllowed = name ? classAutoAllowed.includes(name) : false
if (
(!hasInterrupt || isClassAutoAllowed) &&
(typeof inst?.execute === 'function' || typeof inst?.handleAccept === 'function')
) {
if (isClassAutoAllowed && hasInterrupt) {
logger.info('[toolCallsById] Auto-executing class tool with interrupt (auto-allowed)', {
id,
name,
})
}
setTimeout(() => {
// Guard against duplicate execution - check if already executing or terminal
const currentState = get().toolCallsById[id]?.state
if (currentState === ClientToolCallState.executing || isTerminalState(currentState)) {
return
}
const executingMap = { ...get().toolCallsById }
executingMap[id] = {
...executingMap[id],
state: ClientToolCallState.executing,
display: resolveToolDisplay(name, ClientToolCallState.executing, id, args),
}
set({ toolCallsById: executingMap })
logger.info('[toolCallsById] pending → executing (class)', { id, name })
Promise.resolve()
.then(async () => {
// Use handleAccept for tools with interrupts, execute for others
if (hasInterrupt && typeof inst?.handleAccept === 'function') {
await inst.handleAccept(args || {})
} else {
await inst.execute(args || {})
}
// Success/error will be synced via registerToolStateSync
})
.catch(() => {
const errorMap = { ...get().toolCallsById }
// Do not override terminal review/rejected
if (
isRejectedState(errorMap[id]?.state) ||
isReviewState(errorMap[id]?.state) ||
isBackgroundState(errorMap[id]?.state)
) {
return
}
errorMap[id] = {
...errorMap[id],
state: ClientToolCallState.error,
display: resolveToolDisplay(name, ClientToolCallState.error, id, args),
}
set({ toolCallsById: errorMap })
})
}, 0)
return
}
} catch {}
// Integration tools: Check auto-allowed or stay in pending state until user confirms
// This handles tools like google_calendar_*, exa_*, gmail_read, etc. that aren't in the client registry
// Only relevant if mode is 'build' (agent)
const { mode, workflowId, autoAllowedTools, executeIntegrationTool } = get()
if (mode === 'build' && workflowId) {
// Check if tool was NOT found in client registry
const def = name ? getTool(name) : undefined
const inst = getClientTool(id) as any
if (!def && !inst && name) {
// Check if this integration tool is auto-allowed - if so, execute it immediately
if (autoAllowedTools.includes(name)) {
logger.info('[build mode] Auto-executing integration tool (auto-allowed)', { id, name })
// Defer to allow pending state to render briefly
setTimeout(() => {
executeIntegrationTool(id).catch((err) => {
logger.error('[build mode] Auto-execute integration tool failed', {
id,
name,
error: err,
})
})
}, 0)
} else {
// Integration tools stay in pending state until user confirms
logger.info('[build mode] Integration tool awaiting user confirmation', {
id,
name,
})
}
}
}
return
},
reasoning: (data, context, _get, set) => {
const phase = (data && (data.phase || data?.data?.phase)) as string | undefined
@@ -2082,91 +1859,6 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
if (isPartial) {
return
}
// Execute client tools in parallel (non-blocking) - same pattern as main tool_call handler
// Check if tool is auto-allowed
const { autoAllowedTools: subAgentAutoAllowed } = get()
const isSubAgentAutoAllowed = name ? subAgentAutoAllowed.includes(name) : false
try {
const def = getTool(name)
if (def) {
const hasInterrupt =
typeof def.hasInterrupt === 'function'
? !!def.hasInterrupt(args || {})
: !!def.hasInterrupt
// Auto-execute if no interrupt OR if auto-allowed
if (!hasInterrupt || isSubAgentAutoAllowed) {
if (isSubAgentAutoAllowed && hasInterrupt) {
logger.info('[SubAgent] Auto-executing tool with interrupt (auto-allowed)', {
id,
name,
})
}
// Auto-execute tools - non-blocking
const ctx = createExecutionContext({ toolCallId: id, toolName: name })
Promise.resolve()
.then(() => def.execute(ctx, args || {}))
.catch((execErr: any) => {
logger.error('[SubAgent] Tool execution failed', {
id,
name,
error: execErr?.message,
})
})
}
} else {
// Fallback to class-based tools - non-blocking
const instance = getClientTool(id)
if (instance) {
const hasInterruptDisplays = !!instance.getInterruptDisplays?.()
// Auto-execute if no interrupt OR if auto-allowed
if (!hasInterruptDisplays || isSubAgentAutoAllowed) {
if (isSubAgentAutoAllowed && hasInterruptDisplays) {
logger.info('[SubAgent] Auto-executing class tool with interrupt (auto-allowed)', {
id,
name,
})
}
Promise.resolve()
.then(() => {
// Use handleAccept for tools with interrupts, execute for others
if (hasInterruptDisplays && typeof instance.handleAccept === 'function') {
return instance.handleAccept(args || {})
}
return instance.execute(args || {})
})
.catch((execErr: any) => {
logger.error('[SubAgent] Class tool execution failed', {
id,
name,
error: execErr?.message,
})
})
}
} else {
// Check if this is an integration tool (server-side) that should be auto-executed
const isIntegrationTool = !CLASS_TOOL_METADATA[name]
if (isIntegrationTool && isSubAgentAutoAllowed) {
logger.info('[SubAgent] Auto-executing integration tool (auto-allowed)', {
id,
name,
})
// Execute integration tool via the store method
const { executeIntegrationTool } = get()
executeIntegrationTool(id).catch((err) => {
logger.error('[SubAgent] Integration tool auto-execution failed', {
id,
name,
error: err?.message || err,
})
})
}
}
}
} catch (e: any) {
logger.error('[SubAgent] Tool registry/execution error', { id, name, error: e?.message })
}
},
// Handle subagent tool results
@@ -3208,21 +2900,13 @@ export const useCopilotStore = create<CopilotStore>()(
return { messages }
})
// Notify backend mark-complete to finalize tool server-side
try {
fetch('/api/copilot/tools/mark-complete', {
fetch('/api/copilot/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id,
name: current.name,
status:
targetState === ClientToolCallState.success
? 200
: targetState === ClientToolCallState.rejected
? 409
: 500,
message: toolCallState,
toolCallId: id,
status: toolCallState,
}),
}).catch(() => {})
} catch {}
@@ -3836,150 +3520,6 @@ export const useCopilotStore = create<CopilotStore>()(
setAgentPrefetch: (prefetch) => set({ agentPrefetch: prefetch }),
setEnabledModels: (models) => set({ enabledModels: models }),
executeIntegrationTool: async (toolCallId: string) => {
if (COPILOT_SERVER_ORCHESTRATED) {
return
}
const { toolCallsById, workflowId } = get()
const toolCall = toolCallsById[toolCallId]
if (!toolCall || !workflowId) return
const { id, name, params } = toolCall
// Guard against double execution - skip if already executing or in terminal state
if (toolCall.state === ClientToolCallState.executing || isTerminalState(toolCall.state)) {
logger.info('[executeIntegrationTool] Skipping - already executing or terminal', {
id,
name,
state: toolCall.state,
})
return
}
// Set to executing state
const executingMap = { ...get().toolCallsById }
executingMap[id] = {
...executingMap[id],
state: ClientToolCallState.executing,
display: resolveToolDisplay(name, ClientToolCallState.executing, id, params),
}
set({ toolCallsById: executingMap })
logger.info('[toolCallsById] pending → executing (integration tool)', { id, name })
try {
const res = await fetch('/api/copilot/execute-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
toolCallId: id,
toolName: name,
arguments: params || {},
workflowId,
}),
})
const result = await res.json()
const success = result.success && result.result?.success
const completeMap = { ...get().toolCallsById }
// Do not override terminal review/rejected
if (
isRejectedState(completeMap[id]?.state) ||
isReviewState(completeMap[id]?.state) ||
isBackgroundState(completeMap[id]?.state)
) {
return
}
completeMap[id] = {
...completeMap[id],
state: success ? ClientToolCallState.success : ClientToolCallState.error,
display: resolveToolDisplay(
name,
success ? ClientToolCallState.success : ClientToolCallState.error,
id,
params
),
}
set({ toolCallsById: completeMap })
logger.info(`[toolCallsById] executing → ${success ? 'success' : 'error'} (integration)`, {
id,
name,
result,
})
// Notify backend tool mark-complete endpoint
try {
await fetch('/api/copilot/tools/mark-complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id,
name: name || 'unknown_tool',
status: success ? 200 : 500,
message: success
? result.result?.output?.content
: result.result?.error || result.error || 'Tool execution failed',
data: success
? result.result?.output
: {
error: result.result?.error || result.error,
output: result.result?.output,
},
}),
})
} catch {}
} catch (e) {
const errorMap = { ...get().toolCallsById }
// Do not override terminal review/rejected
if (
isRejectedState(errorMap[id]?.state) ||
isReviewState(errorMap[id]?.state) ||
isBackgroundState(errorMap[id]?.state)
) {
return
}
errorMap[id] = {
...errorMap[id],
state: ClientToolCallState.error,
display: resolveToolDisplay(name, ClientToolCallState.error, id, params),
}
set({ toolCallsById: errorMap })
logger.error('Integration tool execution failed', { id, name, error: e })
}
},
skipIntegrationTool: (toolCallId: string) => {
const { toolCallsById } = get()
const toolCall = toolCallsById[toolCallId]
if (!toolCall) return
const { id, name, params } = toolCall
// Set to rejected state
const rejectedMap = { ...get().toolCallsById }
rejectedMap[id] = {
...rejectedMap[id],
state: ClientToolCallState.rejected,
display: resolveToolDisplay(name, ClientToolCallState.rejected, id, params),
}
set({ toolCallsById: rejectedMap })
logger.info('[toolCallsById] pending → rejected (integration tool skipped)', { id, name })
// Notify backend tool mark-complete endpoint with skip status
fetch('/api/copilot/tools/mark-complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id,
name: name || 'unknown_tool',
status: 200,
message: 'Tool execution skipped by user',
data: { skipped: true },
}),
}).catch(() => {})
},
loadAutoAllowedTools: async () => {
try {
logger.info('[AutoAllowedTools] Loading from API...')
@@ -4013,45 +3553,6 @@ export const useCopilotStore = create<CopilotStore>()(
set({ autoAllowedTools: data.autoAllowedTools || [] })
logger.info('[AutoAllowedTools] Added tool to store', { toolId })
// Auto-execute all pending tools of the same type
const { toolCallsById, executeIntegrationTool } = get()
const pendingToolCalls = Object.values(toolCallsById).filter(
(tc) => tc.name === toolId && tc.state === ClientToolCallState.pending
)
if (pendingToolCalls.length > 0) {
const isIntegrationTool = !CLASS_TOOL_METADATA[toolId]
logger.info('[AutoAllowedTools] Auto-executing pending tools', {
toolId,
count: pendingToolCalls.length,
isIntegrationTool,
})
for (const tc of pendingToolCalls) {
if (isIntegrationTool) {
// Integration tools use executeIntegrationTool
executeIntegrationTool(tc.id).catch((err) => {
logger.error('[AutoAllowedTools] Auto-execute pending integration tool failed', {
toolCallId: tc.id,
toolId,
error: err,
})
})
} else {
// Client tools with interrupts use handleAccept
const inst = getClientTool(tc.id) as any
if (inst && typeof inst.handleAccept === 'function') {
Promise.resolve()
.then(() => inst.handleAccept(tc.params || {}))
.catch((err: any) => {
logger.error('[AutoAllowedTools] Auto-execute pending client tool failed', {
toolCallId: tc.id,
toolId,
error: err,
})
})
}
}
}
}
}
} catch (err) {
logger.error('[AutoAllowedTools] Failed to add tool', { toolId, error: err })

View File

@@ -231,8 +231,6 @@ export interface CopilotActions {
triggerUserMessageId?: string
) => Promise<void>
handleNewChatCreation: (newChatId: string) => Promise<void>
executeIntegrationTool: (toolCallId: string) => Promise<void>
skipIntegrationTool: (toolCallId: string) => void
loadAutoAllowedTools: () => Promise<void>
addAutoAllowedTool: (toolId: string) => Promise<void>
removeAutoAllowedTool: (toolId: string) => Promise<void>