Initial temp state, in the middle of a refactor

This commit is contained in:
Siddharth Ganesan
2026-02-05 12:01:29 -08:00
parent 378b19abdf
commit 0729e37a6e
37 changed files with 759 additions and 1554 deletions

View File

@@ -1,7 +1,7 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/copilot/constants'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { env } from '@/lib/core/config/env'
const GenerateApiKeySchema = z.object({
@@ -17,9 +17,6 @@ export async function POST(req: NextRequest) {
const userId = session.user.id
// Move environment variable access inside the function
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const body = await req.json().catch(() => ({}))
const validationResult = GenerateApiKeySchema.safeParse(body)

View File

@@ -1,6 +1,6 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/copilot/constants'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { env } from '@/lib/core/config/env'
export async function GET(request: NextRequest) {
@@ -12,8 +12,6 @@ export async function GET(request: NextRequest) {
const userId = session.user.id
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const res = await fetch(`${SIM_AGENT_API_URL}/api/validate-key/get-api-keys`, {
method: 'POST',
headers: {
@@ -68,8 +66,6 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: 'id is required' }, { status: 400 })
}
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const res = await fetch(`${SIM_AGENT_API_URL}/api/validate-key/delete`, {
method: 'POST',
headers: {

View File

@@ -23,7 +23,8 @@ const ConfirmationSchema = z.object({
})
/**
* Update tool call status in Redis
* Write the user's tool decision to Redis. The server-side orchestrator's
* waitForToolDecision() polls Redis for this value.
*/
async function updateToolCallStatus(
toolCallId: string,
@@ -32,57 +33,24 @@ async function updateToolCallStatus(
): Promise<boolean> {
const redis = getRedisClient()
if (!redis) {
logger.warn('updateToolCallStatus: Redis client not available')
logger.warn('Redis client not available for tool confirmation')
return false
}
try {
const key = `tool_call:${toolCallId}`
const timeout = 600000 // 10 minutes timeout for user confirmation
const pollInterval = 100 // Poll every 100ms
const startTime = Date.now()
logger.info('Polling for tool call in Redis', { toolCallId, key, timeout })
// Poll until the key exists or timeout
while (Date.now() - startTime < timeout) {
const exists = await redis.exists(key)
if (exists) {
break
}
// Wait before next poll
await new Promise((resolve) => setTimeout(resolve, pollInterval))
}
// Final check if key exists after polling
const exists = await redis.exists(key)
if (!exists) {
logger.warn('Tool call not found in Redis after polling timeout', {
toolCallId,
key,
timeout,
pollDuration: Date.now() - startTime,
})
return false
}
// Store both status and message as JSON
const toolCallData = {
const payload = {
status,
message: message || null,
timestamp: new Date().toISOString(),
}
await redis.set(key, JSON.stringify(toolCallData), 'EX', 86400) // Keep 24 hour expiry
await redis.set(key, JSON.stringify(payload), 'EX', 86400)
return true
} catch (error) {
logger.error('Failed to update tool call status in Redis', {
logger.error('Failed to update tool call status', {
toolCallId,
status,
message,
error: error instanceof Error ? error.message : 'Unknown error',
error: error instanceof Error ? error.message : String(error),
})
return false
}

View File

@@ -0,0 +1,26 @@
import { type NextRequest, NextResponse } from 'next/server'
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request-helpers'
import { routeExecution } from '@/lib/copilot/tools/server/router'
/**
* GET /api/copilot/credentials
* Returns connected OAuth credentials for the authenticated user.
* Used by the copilot store for credential masking.
*/
export async function GET(_req: NextRequest) {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const result = await routeExecution('get_credentials', {}, { userId })
return NextResponse.json({ success: true, result })
} catch (error) {
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to load credentials' },
{ status: 500 }
)
}
}

View File

@@ -17,6 +17,12 @@ const ExecuteSchema = z.object({
payload: z.unknown().optional(),
})
/**
* @deprecated Transitional route used by the legacy client-side tool execution path
* (Zustand store → client tool classes → this route). Will be removed once the
* interactive browser path is fully migrated to server-side orchestration.
* New server-side code should use lib/copilot/orchestrator/tool-executor directly.
*/
export async function POST(req: NextRequest) {
const tracker = createRequestTracker()
try {

View File

@@ -1,247 +0,0 @@
import { db } from '@sim/db'
import { account, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import {
createBadRequestResponse,
createInternalServerErrorResponse,
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import { generateRequestId } from '@/lib/core/utils/request'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
import { executeTool } from '@/tools'
import { getTool, resolveToolId } from '@/tools/utils'
const logger = createLogger('CopilotExecuteToolAPI')
const ExecuteToolSchema = z.object({
toolCallId: z.string(),
toolName: z.string(),
arguments: z.record(z.any()).optional().default({}),
workflowId: z.string().optional(),
})
export async function POST(req: NextRequest) {
const tracker = createRequestTracker()
try {
const session = await getSession()
if (!session?.user?.id) {
return createUnauthorizedResponse()
}
const userId = session.user.id
const body = await req.json()
try {
const preview = JSON.stringify(body).slice(0, 300)
logger.debug(`[${tracker.requestId}] Incoming execute-tool request`, { preview })
} catch {}
const { toolCallId, toolName, arguments: toolArgs, workflowId } = ExecuteToolSchema.parse(body)
const resolvedToolName = resolveToolId(toolName)
logger.info(`[${tracker.requestId}] Executing tool`, {
toolCallId,
toolName,
resolvedToolName,
workflowId,
hasArgs: Object.keys(toolArgs).length > 0,
})
const toolConfig = getTool(resolvedToolName)
if (!toolConfig) {
// Find similar tool names to help debug
const { tools: allTools } = await import('@/tools/registry')
const allToolNames = Object.keys(allTools)
const prefix = toolName.split('_').slice(0, 2).join('_')
const similarTools = allToolNames
.filter((name) => name.startsWith(`${prefix.split('_')[0]}_`))
.slice(0, 10)
logger.warn(`[${tracker.requestId}] Tool not found in registry`, {
toolName,
prefix,
similarTools,
totalToolsInRegistry: allToolNames.length,
})
return NextResponse.json(
{
success: false,
error: `Tool not found: ${toolName}. Similar tools: ${similarTools.join(', ')}`,
toolCallId,
},
{ status: 404 }
)
}
// Get the workspaceId from the workflow (env vars are stored at workspace level)
let workspaceId: string | undefined
if (workflowId) {
const workflowResult = await db
.select({ workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
workspaceId = workflowResult[0]?.workspaceId ?? undefined
}
// Get decrypted environment variables early so we can resolve all {{VAR}} references
const decryptedEnvVars = await getEffectiveDecryptedEnv(userId, workspaceId)
logger.info(`[${tracker.requestId}] Fetched environment variables`, {
workflowId,
workspaceId,
envVarCount: Object.keys(decryptedEnvVars).length,
envVarKeys: Object.keys(decryptedEnvVars),
})
// Build execution params starting with LLM-provided arguments
// Resolve all {{ENV_VAR}} references in the arguments (deep for nested objects)
const executionParams: Record<string, any> = resolveEnvVarReferences(
toolArgs,
decryptedEnvVars,
{ deep: true }
) as Record<string, any>
logger.info(`[${tracker.requestId}] Resolved env var references in arguments`, {
toolName,
originalArgKeys: Object.keys(toolArgs),
resolvedArgKeys: Object.keys(executionParams),
})
// Resolve OAuth access token if required
if (toolConfig.oauth?.required && toolConfig.oauth.provider) {
const provider = toolConfig.oauth.provider
logger.info(`[${tracker.requestId}] Resolving OAuth token`, { provider })
try {
// Find the account for this provider and user
const accounts = await db
.select()
.from(account)
.where(and(eq(account.providerId, provider), eq(account.userId, userId)))
.limit(1)
if (accounts.length > 0) {
const acc = accounts[0]
const requestId = generateRequestId()
const { accessToken } = await refreshTokenIfNeeded(requestId, acc as any, acc.id)
if (accessToken) {
executionParams.accessToken = accessToken
logger.info(`[${tracker.requestId}] OAuth token resolved`, { provider })
} else {
logger.warn(`[${tracker.requestId}] No access token available`, { provider })
return NextResponse.json(
{
success: false,
error: `OAuth token not available for ${provider}. Please reconnect your account.`,
toolCallId,
},
{ status: 400 }
)
}
} else {
logger.warn(`[${tracker.requestId}] No account found for provider`, { provider })
return NextResponse.json(
{
success: false,
error: `No ${provider} account connected. Please connect your account first.`,
toolCallId,
},
{ status: 400 }
)
}
} catch (error) {
logger.error(`[${tracker.requestId}] Failed to resolve OAuth token`, {
provider,
error: error instanceof Error ? error.message : String(error),
})
return NextResponse.json(
{
success: false,
error: `Failed to get OAuth token for ${provider}`,
toolCallId,
},
{ status: 500 }
)
}
}
// Check if tool requires an API key that wasn't resolved via {{ENV_VAR}} reference
const needsApiKey = toolConfig.params?.apiKey?.required
if (needsApiKey && !executionParams.apiKey) {
logger.warn(`[${tracker.requestId}] No API key found for tool`, { toolName })
return NextResponse.json(
{
success: false,
error: `API key not provided for ${toolName}. Use {{YOUR_API_KEY_ENV_VAR}} to reference your environment variable.`,
toolCallId,
},
{ status: 400 }
)
}
// Add execution context
executionParams._context = {
workflowId,
userId,
}
// Special handling for function_execute - inject environment variables
if (toolName === 'function_execute') {
executionParams.envVars = decryptedEnvVars
executionParams.workflowVariables = {} // No workflow variables in copilot context
executionParams.blockData = {} // No block data in copilot context
executionParams.blockNameMapping = {} // No block mapping in copilot context
executionParams.language = executionParams.language || 'javascript'
executionParams.timeout = executionParams.timeout || 30000
logger.info(`[${tracker.requestId}] Injected env vars for function_execute`, {
envVarCount: Object.keys(decryptedEnvVars).length,
})
}
// Execute the tool
logger.info(`[${tracker.requestId}] Executing tool with resolved credentials`, {
toolName,
hasAccessToken: !!executionParams.accessToken,
hasApiKey: !!executionParams.apiKey,
})
const result = await executeTool(resolvedToolName, executionParams)
logger.info(`[${tracker.requestId}] Tool execution complete`, {
toolName,
success: result.success,
hasOutput: !!result.output,
})
return NextResponse.json({
success: true,
toolCallId,
result: {
success: result.success,
output: result.output,
error: result.error,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.debug(`[${tracker.requestId}] Zod validation error`, { issues: error.issues })
return createBadRequestResponse('Invalid request body for execute-tool')
}
logger.error(`[${tracker.requestId}] Failed to execute tool:`, error)
const errorMessage = error instanceof Error ? error.message : 'Failed to execute tool'
return createInternalServerErrorResponse(errorMessage)
}
}

View File

@@ -1,6 +1,6 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/copilot/constants'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
@@ -10,8 +10,6 @@ import {
} from '@/lib/copilot/request-helpers'
import { env } from '@/lib/core/config/env'
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const BodySchema = z.object({
messageId: z.string(),
diffCreated: z.boolean(),

View File

@@ -1,123 +0,0 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/copilot/constants'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
createInternalServerErrorResponse,
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import { env } from '@/lib/core/config/env'
const logger = createLogger('CopilotMarkToolCompleteAPI')
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const MarkCompleteSchema = z.object({
id: z.string(),
name: z.string(),
status: z.number().int(),
message: z.any().optional(),
data: z.any().optional(),
})
/**
* POST /api/copilot/tools/mark-complete
* Proxy to Sim Agent: POST /api/tools/mark-complete
*/
export async function POST(req: NextRequest) {
const tracker = createRequestTracker()
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const body = await req.json()
// Log raw body shape for diagnostics (avoid dumping huge payloads)
try {
const bodyPreview = JSON.stringify(body).slice(0, 300)
logger.debug(`[${tracker.requestId}] Incoming mark-complete raw body preview`, {
preview: `${bodyPreview}${bodyPreview.length === 300 ? '...' : ''}`,
})
} catch {}
const parsed = MarkCompleteSchema.parse(body)
const messagePreview = (() => {
try {
const s =
typeof parsed.message === 'string' ? parsed.message : JSON.stringify(parsed.message)
return s ? `${s.slice(0, 200)}${s.length > 200 ? '...' : ''}` : undefined
} catch {
return undefined
}
})()
logger.info(`[${tracker.requestId}] Forwarding tool mark-complete`, {
userId,
toolCallId: parsed.id,
toolName: parsed.name,
status: parsed.status,
hasMessage: parsed.message !== undefined,
hasData: parsed.data !== undefined,
messagePreview,
agentUrl: `${SIM_AGENT_API_URL}/api/tools/mark-complete`,
})
const agentRes = await fetch(`${SIM_AGENT_API_URL}/api/tools/mark-complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
},
body: JSON.stringify(parsed),
})
// Attempt to parse agent response JSON
let agentJson: any = null
let agentText: string | null = null
try {
agentJson = await agentRes.json()
} catch (_) {
try {
agentText = await agentRes.text()
} catch {}
}
logger.info(`[${tracker.requestId}] Agent responded to mark-complete`, {
status: agentRes.status,
ok: agentRes.ok,
responseJsonPreview: agentJson ? JSON.stringify(agentJson).slice(0, 300) : undefined,
responseTextPreview: agentText ? agentText.slice(0, 300) : undefined,
})
if (agentRes.ok) {
return NextResponse.json({ success: true })
}
const errorMessage =
agentJson?.error || agentText || `Agent responded with status ${agentRes.status}`
const status = agentRes.status >= 500 ? 500 : 400
logger.warn(`[${tracker.requestId}] Mark-complete failed`, {
status,
error: errorMessage,
})
return NextResponse.json({ success: false, error: errorMessage }, { status })
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${tracker.requestId}] Invalid mark-complete request body`, {
issues: error.issues,
})
return createBadRequestResponse('Invalid request body for mark-complete')
}
logger.error(`[${tracker.requestId}] Failed to proxy mark-complete:`, error)
return createInternalServerErrorResponse('Failed to mark tool as complete')
}
}

View File

@@ -14,12 +14,15 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getCopilotModel } from '@/lib/copilot/config'
import { SIM_AGENT_VERSION } from '@/lib/copilot/constants'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { orchestrateSubagentStream } from '@/lib/copilot/orchestrator/subagent'
import {
executeToolServerSide,
prepareExecutionContext,
} from '@/lib/copilot/orchestrator/tool-executor'
import { DIRECT_TOOL_DEFS, SUBAGENT_TOOL_DEFS } from '@/lib/copilot/tools/mcp/definitions'
import { resolveWorkflowIdForUser } from '@/lib/workflows/utils'
const logger = createLogger('CopilotMcpAPI')
@@ -336,12 +339,95 @@ async function handleDirectToolCall(
}
}
/**
* Build mode uses the main chat orchestrator with the 'fast' command instead of
* the subagent endpoint. In Go, 'build' is not a registered subagent — it's a mode
* (ModeFast) on the main chat processor that bypasses subagent orchestration and
* executes all tools directly.
*/
async function handleBuildToolCall(
id: RequestId,
args: Record<string, unknown>,
userId: string
): Promise<NextResponse> {
try {
const requestText = (args.request as string) || JSON.stringify(args)
const { model } = getCopilotModel('chat')
const workflowId = args.workflowId as string | undefined
const resolved = workflowId
? { workflowId }
: await resolveWorkflowIdForUser(userId)
if (!resolved?.workflowId) {
const response: CallToolResult = {
content: [{ type: 'text', text: JSON.stringify({ success: false, error: 'workflowId is required for build. Call create_workflow first.' }, null, 2) }],
isError: true,
}
return NextResponse.json(createResponse(id, response))
}
const chatId = crypto.randomUUID()
const context = (args.context as Record<string, unknown>) || {}
const requestPayload = {
message: requestText,
workflowId: resolved.workflowId,
userId,
stream: true,
streamToolCalls: true,
model,
mode: 'agent',
commands: ['fast'],
messageId: crypto.randomUUID(),
version: SIM_AGENT_VERSION,
headless: true,
chatId,
context,
}
const result = await orchestrateCopilotStream(requestPayload, {
userId,
workflowId: resolved.workflowId,
chatId,
autoExecuteTools: true,
timeout: 300000,
interactive: false,
})
const responseData = {
success: result.success,
content: result.content,
toolCalls: result.toolCalls,
error: result.error,
}
const response: CallToolResult = {
content: [{ type: 'text', text: JSON.stringify(responseData, null, 2) }],
isError: !result.success,
}
return NextResponse.json(createResponse(id, response))
} catch (error) {
logger.error('Build tool call failed', { error })
return NextResponse.json(
createError(id, ErrorCode.InternalError, `Build failed: ${error}`),
{ status: 500 }
)
}
}
async function handleSubagentToolCall(
id: RequestId,
toolDef: (typeof SUBAGENT_TOOL_DEFS)[number],
args: Record<string, unknown>,
userId: string
): Promise<NextResponse> {
// Build mode uses the main chat endpoint, not the subagent endpoint
if (toolDef.agentId === 'build') {
return handleBuildToolCall(id, args, userId)
}
const requestText =
(args.request as string) ||
(args.message as string) ||
@@ -363,8 +449,6 @@ async function handleSubagentToolCall(
workspaceId: args.workspaceId,
context,
model,
// Signal to the copilot backend that this is a headless request
// so it can enforce workflowId requirements on tools
headless: true,
},
{
@@ -374,9 +458,6 @@ async function handleSubagentToolCall(
}
)
// When a respond tool (plan_respond, edit_respond, etc.) was used,
// return only the structured result - not the full result with all internal tool calls.
// This provides clean output for MCP consumers.
let responseData: unknown
if (result.structuredResult) {
responseData = {
@@ -392,7 +473,6 @@ async function handleSubagentToolCall(
errors: result.errors,
}
} else {
// Fallback: return content if no structured result
responseData = {
success: result.success,
content: result.content,

View File

@@ -1,2 +1,7 @@
import { env } from '@/lib/core/config/env'
export const SIM_AGENT_API_URL_DEFAULT = 'https://copilot.sim.ai'
export const SIM_AGENT_VERSION = '1.0.3'
/** Resolved copilot backend URL — reads from env with fallback to default. */
export const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT

View File

@@ -19,6 +19,7 @@ export const INTERRUPT_TOOL_SET = new Set<string>(INTERRUPT_TOOL_NAMES)
export const SUBAGENT_TOOL_NAMES = [
'debug',
'edit',
'build',
'plan',
'test',
'deploy',
@@ -31,6 +32,26 @@ export const SUBAGENT_TOOL_NAMES = [
'workflow',
'evaluate',
'superagent',
'discovery',
] as const
export const SUBAGENT_TOOL_SET = new Set<string>(SUBAGENT_TOOL_NAMES)
/**
* Respond tools are internal to the copilot's subagent system.
* They're used by subagents to signal completion and should NOT be executed by the sim side.
* The copilot backend handles these internally.
*/
export const RESPOND_TOOL_NAMES = [
'plan_respond',
'edit_respond',
'build_respond',
'debug_respond',
'info_respond',
'research_respond',
'deploy_respond',
'superagent_respond',
'discovery_respond',
] as const
export const RESPOND_TOOL_SET = new Set<string>(RESPOND_TOOL_NAMES)

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/copilot/constants'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { handleSubagentRouting, sseHandlers, subAgentHandlers } from '@/lib/copilot/orchestrator/sse-handlers'
import { env } from '@/lib/core/config/env'
import {
normalizeSseEvent,
shouldSkipToolCallEvent,
@@ -15,10 +16,7 @@ import type {
StreamingContext,
ToolCallSummary,
} from '@/lib/copilot/orchestrator/types'
import { env } from '@/lib/core/config/env'
const logger = createLogger('CopilotOrchestrator')
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
export interface OrchestrateStreamOptions extends OrchestratorOptions {
userId: string
@@ -103,7 +101,8 @@ export async function orchestrateCopilotStream(
}
if (normalizedEvent.type === 'subagent_start') {
const toolCallId = normalizedEvent.data?.tool_call_id
const eventData = normalizedEvent.data as Record<string, unknown> | undefined
const toolCallId = eventData?.tool_call_id as string | undefined
if (toolCallId) {
context.subAgentParentToolCallId = toolCallId
context.subAgentContent[toolCallId] = ''

View File

@@ -1,120 +1,8 @@
import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { getRedisClient } from '@/lib/core/config/redis'
const logger = createLogger('CopilotOrchestratorPersistence')
/**
* Create a new copilot chat record.
*/
export async function createChat(params: {
userId: string
workflowId: string
model: string
}): Promise<{ id: string }> {
const [chat] = await db
.insert(copilotChats)
.values({
userId: params.userId,
workflowId: params.workflowId,
model: params.model,
messages: [],
})
.returning({ id: copilotChats.id })
return { id: chat.id }
}
/**
* Load an existing chat for a user.
*/
export async function loadChat(chatId: string, userId: string) {
const [chat] = await db
.select()
.from(copilotChats)
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
.limit(1)
return chat || null
}
/**
* Save chat messages and metadata.
*/
export async function saveMessages(
chatId: string,
messages: any[],
options?: {
title?: string
conversationId?: string
planArtifact?: string | null
config?: { mode?: string; model?: string }
}
): Promise<void> {
await db
.update(copilotChats)
.set({
messages,
updatedAt: new Date(),
...(options?.title ? { title: options.title } : {}),
...(options?.conversationId ? { conversationId: options.conversationId } : {}),
...(options?.planArtifact !== undefined ? { planArtifact: options.planArtifact } : {}),
...(options?.config ? { config: options.config } : {}),
})
.where(eq(copilotChats.id, chatId))
}
/**
* Update the conversationId for a chat without overwriting messages.
*/
export async function updateChatConversationId(
chatId: string,
conversationId: string
): Promise<void> {
await db
.update(copilotChats)
.set({
conversationId,
updatedAt: new Date(),
})
.where(eq(copilotChats.id, chatId))
}
/**
* Set a tool call confirmation status in Redis.
*/
export async function setToolConfirmation(
toolCallId: string,
status: 'accepted' | 'rejected' | 'background' | 'pending',
message?: string
): Promise<boolean> {
const redis = getRedisClient()
if (!redis) {
logger.warn('Redis client not available for tool confirmation')
return false
}
const key = `tool_call:${toolCallId}`
const payload = {
status,
message: message || null,
timestamp: new Date().toISOString(),
}
try {
await redis.set(key, JSON.stringify(payload), 'EX', 86400)
return true
} catch (error) {
logger.error('Failed to set tool confirmation', {
toolCallId,
error: error instanceof Error ? error.message : String(error),
})
return false
}
}
/**
* Get a tool call confirmation status from Redis.
*/

View File

@@ -1,7 +1,12 @@
import { createLogger } from '@sim/logger'
import { INTERRUPT_TOOL_SET, SUBAGENT_TOOL_SET } from '@/lib/copilot/orchestrator/config'
import {
INTERRUPT_TOOL_SET,
RESPOND_TOOL_SET,
SUBAGENT_TOOL_SET,
} from '@/lib/copilot/orchestrator/config'
import { getToolConfirmation } from '@/lib/copilot/orchestrator/persistence'
import {
asRecord,
getEventData,
markToolResultSeen,
wasToolResultSeen,
@@ -20,22 +25,6 @@ const logger = createLogger('CopilotSseHandlers')
// Normalization + dedupe helpers live in sse-utils to keep server/client in sync.
/**
* Respond tools are internal to the copilot's subagent system.
* They're used by subagents to signal completion and should NOT be executed by the sim side.
* The copilot backend handles these internally.
*/
const RESPOND_TOOL_SET = new Set([
'plan_respond',
'edit_respond',
'debug_respond',
'info_respond',
'research_respond',
'deploy_respond',
'superagent_respond',
'discovery_respond',
])
export type SSEHandler = (
event: SSEEvent,
context: StreamingContext,
@@ -72,15 +61,16 @@ async function executeToolAndReport(
// If create_workflow was successful, update the execution context with the new workflowId
// This ensures subsequent tools in the same stream have access to the workflowId
const output = asRecord(result.output)
if (
toolCall.name === 'create_workflow' &&
result.success &&
result.output?.workflowId &&
output.workflowId &&
!execContext.workflowId
) {
execContext.workflowId = result.output.workflowId
if (result.output.workspaceId) {
execContext.workspaceId = result.output.workspaceId
execContext.workflowId = output.workflowId as string
if (output.workspaceId) {
execContext.workspaceId = output.workspaceId as string
}
}
@@ -145,7 +135,7 @@ async function waitForToolDecision(
export const sseHandlers: Record<string, SSEHandler> = {
chat_id: (event, context) => {
context.chatId = event.data?.chatId
context.chatId = asRecord(event.data).chatId
},
title_updated: () => {},
tool_result: (event, context) => {
@@ -206,7 +196,7 @@ export const sseHandlers: Record<string, SSEHandler> = {
const toolName = toolData.name || event.toolName
if (!toolCallId || !toolName) return
const args = toolData.arguments || toolData.input || event.data?.input
const args = toolData.arguments || toolData.input || asRecord(event.data).input
const isPartial = toolData.partial === true
const existing = context.toolCalls.get(toolCallId)
@@ -323,7 +313,7 @@ export const sseHandlers: Record<string, SSEHandler> = {
}
},
reasoning: (event, context) => {
const phase = event.data?.phase || event.data?.data?.phase
const phase = asRecord(event.data).phase || asRecord(asRecord(event.data).data).phase
if (phase === 'start') {
context.isInThinkingBlock = true
context.currentThinkingBlock = {
@@ -341,34 +331,35 @@ export const sseHandlers: Record<string, SSEHandler> = {
context.currentThinkingBlock = null
return
}
const chunk =
typeof event.data === 'string' ? event.data : event.data?.data || event.data?.content
const d = asRecord(event.data)
const chunk = typeof event.data === 'string' ? event.data : d.data || d.content
if (!chunk || !context.currentThinkingBlock) return
context.currentThinkingBlock.content = `${context.currentThinkingBlock.content || ''}${chunk}`
},
content: (event, context) => {
const chunk =
typeof event.data === 'string' ? event.data : event.data?.content || event.data?.data
const d = asRecord(event.data)
const chunk = typeof event.data === 'string' ? event.data : d.content || d.data
if (!chunk) return
context.accumulatedContent += chunk
addContentBlock(context, { type: 'text', content: chunk })
addContentBlock(context, { type: 'text', content: chunk as string })
},
done: (event, context) => {
if (event.data?.responseId) {
context.conversationId = event.data.responseId
const d = asRecord(event.data)
if (d.responseId) {
context.conversationId = d.responseId as string
}
context.streamComplete = true
},
start: (event, context) => {
if (event.data?.responseId) {
context.conversationId = event.data.responseId
const d = asRecord(event.data)
if (d.responseId) {
context.conversationId = d.responseId as string
}
},
error: (event, context) => {
const d = asRecord(event.data)
const message =
event.data?.message ||
event.data?.error ||
(typeof event.data === 'string' ? event.data : null)
d.message || d.error || (typeof event.data === 'string' ? event.data : null)
if (message) {
context.errors.push(message)
}
@@ -380,7 +371,7 @@ export const subAgentHandlers: Record<string, SSEHandler> = {
content: (event, context) => {
const parentToolCallId = context.subAgentParentToolCallId
if (!parentToolCallId || !event.data) return
const chunk = typeof event.data === 'string' ? event.data : event.data?.content || ''
const chunk = typeof event.data === 'string' ? event.data : asRecord(event.data).content || ''
if (!chunk) return
context.subAgentContent[parentToolCallId] =
(context.subAgentContent[parentToolCallId] || '') + chunk
@@ -394,7 +385,7 @@ export const subAgentHandlers: Record<string, SSEHandler> = {
const toolName = toolData.name || event.toolName
if (!toolCallId || !toolName) return
const isPartial = toolData.partial === true
const args = toolData.arguments || toolData.input || event.data?.input
const args = toolData.arguments || toolData.input || asRecord(event.data).input
const existing = context.toolCalls.get(toolCallId)
// Ignore late/duplicate tool_call events once we already have a result

View File

@@ -2,6 +2,10 @@ import type { SSEEvent } from '@/lib/copilot/orchestrator/types'
type EventDataObject = Record<string, any> | undefined
/** Safely cast event.data to a record for property access. */
export const asRecord = (data: unknown): Record<string, any> =>
(data && typeof data === 'object' && !Array.isArray(data) ? data : {}) as Record<string, any>
const DEFAULT_TOOL_EVENT_TTL_MS = 5 * 60 * 1000
/**
@@ -45,7 +49,7 @@ export const getEventData = (event: SSEEvent): EventDataObject => {
return nested || topLevel
}
export function getToolCallIdFromEvent(event: SSEEvent): string | undefined {
function getToolCallIdFromEvent(event: SSEEvent): string | undefined {
const data = getEventData(event)
return event.toolCallId || data?.id || data?.toolCallId
}
@@ -70,14 +74,14 @@ export function normalizeSseEvent(event: SSEEvent): SSEEvent {
}
}
export function markToolCallSeen(toolCallId: string, ttlMs: number = DEFAULT_TOOL_EVENT_TTL_MS): void {
function markToolCallSeen(toolCallId: string, ttlMs: number = DEFAULT_TOOL_EVENT_TTL_MS): void {
seenToolCalls.add(toolCallId)
setTimeout(() => {
seenToolCalls.delete(toolCallId)
}, ttlMs)
}
export function wasToolCallSeen(toolCallId: string): boolean {
function wasToolCallSeen(toolCallId: string): boolean {
return seenToolCalls.has(toolCallId)
}

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/copilot/constants'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { handleSubagentRouting, sseHandlers, subAgentHandlers } from '@/lib/copilot/orchestrator/sse-handlers'
import { env } from '@/lib/core/config/env'
import {
normalizeSseEvent,
shouldSkipToolCallEvent,
@@ -15,11 +16,9 @@ import type {
StreamingContext,
ToolCallSummary,
} from '@/lib/copilot/orchestrator/types'
import { env } from '@/lib/core/config/env'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
const logger = createLogger('CopilotSubagentOrchestrator')
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
export interface SubagentOrchestratorOptions extends Omit<OrchestratorOptions, 'onComplete'> {
userId: string
@@ -77,7 +76,7 @@ export async function orchestrateSubagentStream(
'Content-Type': 'application/json',
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
},
body: JSON.stringify({ ...requestPayload, stream: true, userId }),
body: JSON.stringify({ ...requestPayload, stream: true }),
signal: abortSignal,
})
@@ -129,7 +128,8 @@ export async function orchestrateSubagentStream(
// Handle subagent_start/subagent_end events to track nested subagent calls
if (normalizedEvent.type === 'subagent_start') {
const toolCallId = normalizedEvent.data?.tool_call_id
const eventData = normalizedEvent.data as Record<string, unknown> | undefined
const toolCallId = eventData?.tool_call_id as string | undefined
if (toolCallId) {
context.subAgentParentToolCallId = toolCallId
context.subAgentContent[toolCallId] = ''

View File

@@ -1,13 +1,12 @@
import crypto from 'crypto'
import { db } from '@sim/db'
import { chat, workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { and, eq, inArray } from 'drizzle-orm'
import { chat, workflow, workflowMcpTool } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types'
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { deployWorkflow, undeployWorkflow } from '@/lib/workflows/persistence/utils'
import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server'
import { checkChatAccess, checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
import { ensureWorkflowAccess } from './access'
import { ensureWorkflowAccess } from '../access'
export async function executeDeployApi(
params: Record<string, any>,
@@ -115,6 +114,11 @@ export async function executeDeployChat(
return { success: false, error: deployResult.error || 'Failed to deploy workflow' }
}
const existingCustomizations =
(existingDeployment?.customizations as
| { primaryColor?: string; welcomeMessage?: string }
| undefined) || {}
const payload = {
workflowId,
identifier,
@@ -122,12 +126,10 @@ export async function executeDeployChat(
description: String(params.description || existingDeployment?.description || ''),
customizations: {
primaryColor:
params.customizations?.primaryColor ||
existingDeployment?.customizations?.primaryColor ||
params.customizations?.primaryColor || existingCustomizations.primaryColor ||
'var(--brand-primary-hover-hex)',
welcomeMessage:
params.customizations?.welcomeMessage ||
existingDeployment?.customizations?.welcomeMessage ||
params.customizations?.welcomeMessage || existingCustomizations.welcomeMessage ||
'Hi there! How can I help you today?',
},
authType: params.authType || existingDeployment?.authType || 'public',
@@ -277,203 +279,3 @@ export async function executeRedeploy(context: ExecutionContext): Promise<ToolCa
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function executeCheckDeploymentStatus(
params: Record<string, any>,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
const workspaceId = workflowRecord.workspaceId
const [apiDeploy, chatDeploy] = await Promise.all([
db.select().from(workflow).where(eq(workflow.id, workflowId)).limit(1),
db.select().from(chat).where(eq(chat.workflowId, workflowId)).limit(1),
])
const isApiDeployed = apiDeploy[0]?.isDeployed || false
const apiDetails = {
isDeployed: isApiDeployed,
deployedAt: apiDeploy[0]?.deployedAt || null,
endpoint: isApiDeployed ? `/api/workflows/${workflowId}/execute` : null,
apiKey: workflowRecord.workspaceId ? 'Workspace API keys' : 'Personal API keys',
needsRedeployment: false,
}
const isChatDeployed = !!chatDeploy[0]
const chatDetails = {
isDeployed: isChatDeployed,
chatId: chatDeploy[0]?.id || null,
identifier: chatDeploy[0]?.identifier || null,
chatUrl: isChatDeployed ? `/chat/${chatDeploy[0]?.identifier}` : null,
title: chatDeploy[0]?.title || null,
description: chatDeploy[0]?.description || null,
authType: chatDeploy[0]?.authType || null,
allowedEmails: chatDeploy[0]?.allowedEmails || null,
outputConfigs: chatDeploy[0]?.outputConfigs || null,
welcomeMessage: chatDeploy[0]?.customizations?.welcomeMessage || null,
primaryColor: chatDeploy[0]?.customizations?.primaryColor || null,
hasPassword: Boolean(chatDeploy[0]?.password),
}
const mcpDetails = { isDeployed: false, servers: [] as any[] }
if (workspaceId) {
const servers = await db
.select({
serverId: workflowMcpServer.id,
serverName: workflowMcpServer.name,
toolName: workflowMcpTool.toolName,
toolDescription: workflowMcpTool.toolDescription,
parameterSchema: workflowMcpTool.parameterSchema,
toolId: workflowMcpTool.id,
})
.from(workflowMcpTool)
.innerJoin(workflowMcpServer, eq(workflowMcpTool.serverId, workflowMcpServer.id))
.where(eq(workflowMcpTool.workflowId, workflowId))
if (servers.length > 0) {
mcpDetails.isDeployed = true
mcpDetails.servers = servers
}
}
const isDeployed = apiDetails.isDeployed || chatDetails.isDeployed || mcpDetails.isDeployed
return {
success: true,
output: { isDeployed, api: apiDetails, chat: chatDetails, mcp: mcpDetails },
}
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function executeListWorkspaceMcpServers(
params: Record<string, any>,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
const workspaceId = workflowRecord.workspaceId
if (!workspaceId) {
return { success: false, error: 'workspaceId is required' }
}
const servers = await db
.select({
id: workflowMcpServer.id,
name: workflowMcpServer.name,
description: workflowMcpServer.description,
})
.from(workflowMcpServer)
.where(eq(workflowMcpServer.workspaceId, workspaceId))
const serverIds = servers.map((server) => server.id)
const tools =
serverIds.length > 0
? await db
.select({
serverId: workflowMcpTool.serverId,
toolName: workflowMcpTool.toolName,
})
.from(workflowMcpTool)
.where(inArray(workflowMcpTool.serverId, serverIds))
: []
const toolNamesByServer: Record<string, string[]> = {}
for (const tool of tools) {
if (!toolNamesByServer[tool.serverId]) {
toolNamesByServer[tool.serverId] = []
}
toolNamesByServer[tool.serverId].push(tool.toolName)
}
const serversWithToolNames = servers.map((server) => ({
...server,
toolCount: toolNamesByServer[server.id]?.length || 0,
toolNames: toolNamesByServer[server.id] || [],
}))
return { success: true, output: { servers: serversWithToolNames, count: servers.length } }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function executeCreateWorkspaceMcpServer(
params: Record<string, any>,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
const workspaceId = workflowRecord.workspaceId
if (!workspaceId) {
return { success: false, error: 'workspaceId is required' }
}
const name = params.name?.trim()
if (!name) {
return { success: false, error: 'name is required' }
}
const serverId = crypto.randomUUID()
const [server] = await db
.insert(workflowMcpServer)
.values({
id: serverId,
workspaceId,
createdBy: context.userId,
name,
description: params.description?.trim() || null,
isPublic: params.isPublic ?? false,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
const workflowIds: string[] = params.workflowIds || []
const addedTools: Array<{ workflowId: string; toolName: string }> = []
if (workflowIds.length > 0) {
const workflows = await db.select().from(workflow).where(inArray(workflow.id, workflowIds))
for (const wf of workflows) {
if (wf.workspaceId !== workspaceId || !wf.isDeployed) {
continue
}
const hasStartBlock = await hasValidStartBlock(wf.id)
if (!hasStartBlock) {
continue
}
const toolName = sanitizeToolName(wf.name || `workflow_${wf.id}`)
await db.insert(workflowMcpTool).values({
id: crypto.randomUUID(),
serverId,
workflowId: wf.id,
toolName,
toolDescription: wf.description || `Execute ${wf.name} workflow`,
parameterSchema: {},
createdAt: new Date(),
updatedAt: new Date(),
})
addedTools.push({ workflowId: wf.id, toolName })
}
}
return { success: true, output: { server, addedTools } }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}

View File

@@ -0,0 +1,2 @@
export * from './deploy'
export * from './manage'

View File

@@ -0,0 +1,211 @@
import crypto from 'crypto'
import { db } from '@sim/db'
import { chat, workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { eq, inArray } from 'drizzle-orm'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types'
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server'
import { ensureWorkflowAccess } from '../access'
export async function executeCheckDeploymentStatus(
params: Record<string, any>,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
const workspaceId = workflowRecord.workspaceId
const [apiDeploy, chatDeploy] = await Promise.all([
db.select().from(workflow).where(eq(workflow.id, workflowId)).limit(1),
db.select().from(chat).where(eq(chat.workflowId, workflowId)).limit(1),
])
const isApiDeployed = apiDeploy[0]?.isDeployed || false
const apiDetails = {
isDeployed: isApiDeployed,
deployedAt: apiDeploy[0]?.deployedAt || null,
endpoint: isApiDeployed ? `/api/workflows/${workflowId}/execute` : null,
apiKey: workflowRecord.workspaceId ? 'Workspace API keys' : 'Personal API keys',
needsRedeployment: false,
}
const isChatDeployed = !!chatDeploy[0]
const chatCustomizations =
(chatDeploy[0]?.customizations as
| { welcomeMessage?: string; primaryColor?: string }
| undefined) || {}
const chatDetails = {
isDeployed: isChatDeployed,
chatId: chatDeploy[0]?.id || null,
identifier: chatDeploy[0]?.identifier || null,
chatUrl: isChatDeployed ? `/chat/${chatDeploy[0]?.identifier}` : null,
title: chatDeploy[0]?.title || null,
description: chatDeploy[0]?.description || null,
authType: chatDeploy[0]?.authType || null,
allowedEmails: chatDeploy[0]?.allowedEmails || null,
outputConfigs: chatDeploy[0]?.outputConfigs || null,
welcomeMessage: chatCustomizations.welcomeMessage || null,
primaryColor: chatCustomizations.primaryColor || null,
hasPassword: Boolean(chatDeploy[0]?.password),
}
const mcpDetails = { isDeployed: false, servers: [] as any[] }
if (workspaceId) {
const servers = await db
.select({
serverId: workflowMcpServer.id,
serverName: workflowMcpServer.name,
toolName: workflowMcpTool.toolName,
toolDescription: workflowMcpTool.toolDescription,
parameterSchema: workflowMcpTool.parameterSchema,
toolId: workflowMcpTool.id,
})
.from(workflowMcpTool)
.innerJoin(workflowMcpServer, eq(workflowMcpTool.serverId, workflowMcpServer.id))
.where(eq(workflowMcpTool.workflowId, workflowId))
if (servers.length > 0) {
mcpDetails.isDeployed = true
mcpDetails.servers = servers
}
}
const isDeployed = apiDetails.isDeployed || chatDetails.isDeployed || mcpDetails.isDeployed
return {
success: true,
output: { isDeployed, api: apiDetails, chat: chatDetails, mcp: mcpDetails },
}
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function executeListWorkspaceMcpServers(
params: Record<string, any>,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
const workspaceId = workflowRecord.workspaceId
if (!workspaceId) {
return { success: false, error: 'workspaceId is required' }
}
const servers = await db
.select({
id: workflowMcpServer.id,
name: workflowMcpServer.name,
description: workflowMcpServer.description,
})
.from(workflowMcpServer)
.where(eq(workflowMcpServer.workspaceId, workspaceId))
const serverIds = servers.map((server) => server.id)
const tools =
serverIds.length > 0
? await db
.select({
serverId: workflowMcpTool.serverId,
toolName: workflowMcpTool.toolName,
})
.from(workflowMcpTool)
.where(inArray(workflowMcpTool.serverId, serverIds))
: []
const toolNamesByServer: Record<string, string[]> = {}
for (const tool of tools) {
if (!toolNamesByServer[tool.serverId]) {
toolNamesByServer[tool.serverId] = []
}
toolNamesByServer[tool.serverId].push(tool.toolName)
}
const serversWithToolNames = servers.map((server) => ({
...server,
toolCount: toolNamesByServer[server.id]?.length || 0,
toolNames: toolNamesByServer[server.id] || [],
}))
return { success: true, output: { servers: serversWithToolNames, count: servers.length } }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function executeCreateWorkspaceMcpServer(
params: Record<string, any>,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
const workspaceId = workflowRecord.workspaceId
if (!workspaceId) {
return { success: false, error: 'workspaceId is required' }
}
const name = params.name?.trim()
if (!name) {
return { success: false, error: 'name is required' }
}
const serverId = crypto.randomUUID()
const [server] = await db
.insert(workflowMcpServer)
.values({
id: serverId,
workspaceId,
createdBy: context.userId,
name,
description: params.description?.trim() || null,
isPublic: params.isPublic ?? false,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
const workflowIds: string[] = params.workflowIds || []
const addedTools: Array<{ workflowId: string; toolName: string }> = []
if (workflowIds.length > 0) {
const workflows = await db.select().from(workflow).where(inArray(workflow.id, workflowIds))
for (const wf of workflows) {
if (wf.workspaceId !== workspaceId || !wf.isDeployed) {
continue
}
const hasStartBlock = await hasValidStartBlock(wf.id)
if (!hasStartBlock) {
continue
}
const toolName = sanitizeToolName(wf.name || `workflow_${wf.id}`)
await db.insert(workflowMcpTool).values({
id: crypto.randomUUID(),
serverId,
workflowId: wf.id,
toolName,
toolDescription: wf.description || `Execute ${wf.name} workflow`,
parameterSchema: {},
createdAt: new Date(),
updatedAt: new Date(),
})
addedTools.push({ workflowId: wf.id, toolName })
}
}
return { success: true, output: { server, addedTools } }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}

View File

@@ -2,7 +2,7 @@ import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/copilot/constants'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import type {
ExecutionContext,
ToolCallResult,
@@ -11,7 +11,7 @@ import type {
import { routeExecution } from '@/lib/copilot/tools/server/router'
import { env } from '@/lib/core/config/env'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
import { executeIntegrationToolDirect } from '@/lib/copilot/orchestrator/tool-executor/integration-tools'
import { executeIntegrationToolDirect } from './integration-tools'
import {
executeGetBlockOutputs,
executeGetBlockUpstreamReferences,
@@ -25,7 +25,7 @@ import {
executeCreateFolder,
executeRunWorkflow,
executeSetGlobalWorkflowVariables,
} from '@/lib/copilot/orchestrator/tool-executor/workflow-tools'
} from './workflow-tools'
import {
executeCheckDeploymentStatus,
executeCreateWorkspaceMcpServer,
@@ -34,11 +34,10 @@ import {
executeDeployMcp,
executeListWorkspaceMcpServers,
executeRedeploy,
} from '@/lib/copilot/orchestrator/tool-executor/deployment-tools'
} from './deployment-tools'
import { getTool, resolveToolId } from '@/tools/utils'
const logger = createLogger('CopilotToolExecutor')
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const SERVER_TOOLS = new Set<string>([
'get_blocks_and_tools',

View File

@@ -0,0 +1,2 @@
export * from './queries'
export * from './mutations'

View File

@@ -0,0 +1,251 @@
import crypto from 'crypto'
import { db } from '@sim/db'
import { workflow, workflowFolder } from '@sim/db/schema'
import { and, eq, isNull, max } from 'drizzle-orm'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { ensureWorkflowAccess, ensureWorkspaceAccess, getDefaultWorkspaceId } from '../access'
export async function executeCreateWorkflow(
params: Record<string, any>,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const name = typeof params?.name === 'string' ? params.name.trim() : ''
if (!name) {
return { success: false, error: 'name is required' }
}
const workspaceId = params?.workspaceId || (await getDefaultWorkspaceId(context.userId))
const folderId = params?.folderId || null
const description = typeof params?.description === 'string' ? params.description : null
await ensureWorkspaceAccess(workspaceId, context.userId, true)
const workflowId = crypto.randomUUID()
const now = new Date()
const folderCondition = folderId ? eq(workflow.folderId, folderId) : isNull(workflow.folderId)
const [maxResult] = await db
.select({ maxOrder: max(workflow.sortOrder) })
.from(workflow)
.where(and(eq(workflow.workspaceId, workspaceId), folderCondition))
const sortOrder = (maxResult?.maxOrder ?? 0) + 1
await db.insert(workflow).values({
id: workflowId,
userId: context.userId,
workspaceId,
folderId,
sortOrder,
name,
description,
color: '#3972F6',
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false,
runCount: 0,
variables: {},
})
const { workflowState } = buildDefaultWorkflowArtifacts()
const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState)
if (!saveResult.success) {
throw new Error(saveResult.error || 'Failed to save workflow state')
}
return {
success: true,
output: {
workflowId,
workflowName: name,
workspaceId,
folderId,
},
}
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function executeCreateFolder(
params: Record<string, any>,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const name = typeof params?.name === 'string' ? params.name.trim() : ''
if (!name) {
return { success: false, error: 'name is required' }
}
const workspaceId = params?.workspaceId || (await getDefaultWorkspaceId(context.userId))
const parentId = params?.parentId || null
await ensureWorkspaceAccess(workspaceId, context.userId, true)
const [maxResult] = await db
.select({ maxOrder: max(workflowFolder.sortOrder) })
.from(workflowFolder)
.where(
and(
eq(workflowFolder.workspaceId, workspaceId),
parentId ? eq(workflowFolder.parentId, parentId) : isNull(workflowFolder.parentId)
)
)
const sortOrder = (maxResult?.maxOrder ?? 0) + 1
const folderId = crypto.randomUUID()
await db.insert(workflowFolder).values({
id: folderId,
userId: context.userId,
workspaceId,
parentId,
name,
sortOrder,
createdAt: new Date(),
updatedAt: new Date(),
})
return { success: true, output: { folderId, name, workspaceId, parentId } }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function executeRunWorkflow(
params: Record<string, any>,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
const result = await executeWorkflow(
{
id: workflowRecord.id,
userId: workflowRecord.userId,
workspaceId: workflowRecord.workspaceId,
variables: workflowRecord.variables || {},
},
generateRequestId(),
params.workflow_input || params.input || undefined,
context.userId
)
return {
success: result.success,
output: {
executionId: result.metadata?.executionId,
success: result.success,
output: result.output,
logs: result.logs,
},
error: result.success ? undefined : result.error || 'Workflow execution failed',
}
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function executeSetGlobalWorkflowVariables(
params: Record<string, any>,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
const operations = Array.isArray(params.operations) ? params.operations : []
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
const currentVarsRecord = (workflowRecord.variables as Record<string, any>) || {}
const byName: Record<string, any> = {}
Object.values(currentVarsRecord).forEach((v: any) => {
if (v && typeof v === 'object' && v.id && v.name) byName[String(v.name)] = v
})
for (const op of operations) {
const key = String(op?.name || '')
if (!key) continue
const nextType = op?.type || byName[key]?.type || 'plain'
const coerceValue = (value: any, type: string) => {
if (value === undefined) return value
if (type === 'number') {
const n = Number(value)
return Number.isNaN(n) ? value : n
}
if (type === 'boolean') {
const v = String(value).trim().toLowerCase()
if (v === 'true') return true
if (v === 'false') return false
return value
}
if (type === 'array' || type === 'object') {
try {
const parsed = JSON.parse(String(value))
if (type === 'array' && Array.isArray(parsed)) return parsed
if (type === 'object' && parsed && typeof parsed === 'object' && !Array.isArray(parsed))
return parsed
} catch {}
return value
}
return value
}
if (op.operation === 'delete') {
delete byName[key]
continue
}
const typedValue = coerceValue(op.value, nextType)
if (op.operation === 'add') {
byName[key] = {
id: crypto.randomUUID(),
workflowId,
name: key,
type: nextType,
value: typedValue,
}
continue
}
if (op.operation === 'edit') {
if (!byName[key]) {
byName[key] = {
id: crypto.randomUUID(),
workflowId,
name: key,
type: nextType,
value: typedValue,
}
} else {
byName[key] = {
...byName[key],
type: nextType,
value: typedValue,
}
}
}
}
const nextVarsRecord = Object.fromEntries(
Object.values(byName).map((v: any) => [String(v.id), v])
)
await db
.update(workflow)
.set({ variables: nextVarsRecord, updatedAt: new Date() })
.where(eq(workflow.id, workflowId))
return { success: true, output: { updated: Object.values(byName).length } }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}

View File

@@ -1,26 +1,24 @@
import crypto from 'crypto'
import { db } from '@sim/db'
import { customTools, permissions, workflow, workflowFolder, workspace } from '@sim/db/schema'
import { and, asc, desc, eq, inArray, isNull, max, or } from 'drizzle-orm'
import { and, asc, desc, eq, isNull, or } from 'drizzle-orm'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types'
import {
extractWorkflowNames,
formatNormalizedWorkflowForCopilot,
normalizeWorkflowName,
} from '@/lib/copilot/tools/shared/workflow-utils'
import { generateRequestId } from '@/lib/core/utils/request'
import { mcpService } from '@/lib/mcp/service'
import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace'
import { getBlockOutputPaths } from '@/lib/workflows/blocks/block-outputs'
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow'
import {
loadWorkflowFromNormalizedTables,
saveWorkflowToNormalizedTables,
} from '@/lib/workflows/persistence/utils'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import { ensureWorkflowAccess, ensureWorkspaceAccess, getAccessibleWorkflowsForUser, getDefaultWorkspaceId } from './access'
import {
ensureWorkflowAccess,
ensureWorkspaceAccess,
getAccessibleWorkflowsForUser,
getDefaultWorkspaceId,
} from '../access'
import { normalizeName } from '@/executor/constants'
export async function executeGetUserWorkflow(
@@ -180,112 +178,6 @@ export async function executeListFolders(
}
}
export async function executeCreateWorkflow(
params: Record<string, any>,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const name = typeof params?.name === 'string' ? params.name.trim() : ''
if (!name) {
return { success: false, error: 'name is required' }
}
const workspaceId = params?.workspaceId || (await getDefaultWorkspaceId(context.userId))
const folderId = params?.folderId || null
const description = typeof params?.description === 'string' ? params.description : null
await ensureWorkspaceAccess(workspaceId, context.userId, true)
const workflowId = crypto.randomUUID()
const now = new Date()
const folderCondition = folderId ? eq(workflow.folderId, folderId) : isNull(workflow.folderId)
const [maxResult] = await db
.select({ maxOrder: max(workflow.sortOrder) })
.from(workflow)
.where(and(eq(workflow.workspaceId, workspaceId), folderCondition))
const sortOrder = (maxResult?.maxOrder ?? 0) + 1
await db.insert(workflow).values({
id: workflowId,
userId: context.userId,
workspaceId,
folderId,
sortOrder,
name,
description,
color: '#3972F6',
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false,
runCount: 0,
variables: {},
})
const { workflowState } = buildDefaultWorkflowArtifacts()
const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState)
if (!saveResult.success) {
throw new Error(saveResult.error || 'Failed to save workflow state')
}
return {
success: true,
output: {
workflowId,
workflowName: name,
workspaceId,
folderId,
},
}
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function executeCreateFolder(
params: Record<string, any>,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const name = typeof params?.name === 'string' ? params.name.trim() : ''
if (!name) {
return { success: false, error: 'name is required' }
}
const workspaceId = params?.workspaceId || (await getDefaultWorkspaceId(context.userId))
const parentId = params?.parentId || null
await ensureWorkspaceAccess(workspaceId, context.userId, true)
const [maxResult] = await db
.select({ maxOrder: max(workflowFolder.sortOrder) })
.from(workflowFolder)
.where(
and(
eq(workflowFolder.workspaceId, workspaceId),
parentId ? eq(workflowFolder.parentId, parentId) : isNull(workflowFolder.parentId)
)
)
const sortOrder = (maxResult?.maxOrder ?? 0) + 1
const folderId = crypto.randomUUID()
await db.insert(workflowFolder).values({
id: folderId,
workspaceId,
parentId,
name,
sortOrder,
createdAt: new Date(),
updatedAt: new Date(),
})
return { success: true, output: { folderId, name, workspaceId, parentId } }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function executeGetWorkflowData(
params: Record<string, any>,
context: ExecutionContext
@@ -587,140 +479,6 @@ export async function executeGetBlockUpstreamReferences(
}
}
export async function executeRunWorkflow(
params: Record<string, any>,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
const result = await executeWorkflow(
{
id: workflowRecord.id,
userId: workflowRecord.userId,
workspaceId: workflowRecord.workspaceId,
variables: workflowRecord.variables || {},
},
generateRequestId(),
params.workflow_input || params.input || undefined,
context.userId
)
return {
success: result.success,
output: {
executionId: result.executionId,
success: result.success,
output: result.output,
logs: result.logs,
},
error: result.success ? undefined : result.error || 'Workflow execution failed',
}
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function executeSetGlobalWorkflowVariables(
params: Record<string, any>,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
const operations = Array.isArray(params.operations) ? params.operations : []
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
const currentVarsRecord = (workflowRecord.variables as Record<string, any>) || {}
const byName: Record<string, any> = {}
Object.values(currentVarsRecord).forEach((v: any) => {
if (v && typeof v === 'object' && v.id && v.name) byName[String(v.name)] = v
})
for (const op of operations) {
const key = String(op?.name || '')
if (!key) continue
const nextType = op?.type || byName[key]?.type || 'plain'
const coerceValue = (value: any, type: string) => {
if (value === undefined) return value
if (type === 'number') {
const n = Number(value)
return Number.isNaN(n) ? value : n
}
if (type === 'boolean') {
const v = String(value).trim().toLowerCase()
if (v === 'true') return true
if (v === 'false') return false
return value
}
if (type === 'array' || type === 'object') {
try {
const parsed = JSON.parse(String(value))
if (type === 'array' && Array.isArray(parsed)) return parsed
if (type === 'object' && parsed && typeof parsed === 'object' && !Array.isArray(parsed))
return parsed
} catch {}
return value
}
return value
}
if (op.operation === 'delete') {
delete byName[key]
continue
}
const typedValue = coerceValue(op.value, nextType)
if (op.operation === 'add') {
byName[key] = {
id: crypto.randomUUID(),
workflowId,
name: key,
type: nextType,
value: typedValue,
}
continue
}
if (op.operation === 'edit') {
if (!byName[key]) {
byName[key] = {
id: crypto.randomUUID(),
workflowId,
name: key,
type: nextType,
value: typedValue,
}
} else {
byName[key] = {
...byName[key],
type: nextType,
value: typedValue,
}
}
}
}
const nextVarsRecord = Object.fromEntries(
Object.values(byName).map((v: any) => [String(v.id), v])
)
await db
.update(workflow)
.set({ variables: nextVarsRecord, updatedAt: new Date() })
.where(eq(workflow.id, workflowId))
return { success: true, output: { updated: Object.values(byName).length } }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
async function getWorkflowVariablesForTool(
workflowId: string
): Promise<Array<{ id: string; name: string; type: string; tag: string }>> {

View File

@@ -19,12 +19,12 @@ export type SSEEventType =
export interface SSEEvent {
type: SSEEventType
data?: any
data?: unknown
subagent?: string
toolCallId?: string
toolName?: string
success?: boolean
result?: any
result?: unknown
}
export type ToolCallStatus = 'pending' | 'executing' | 'success' | 'error' | 'skipped' | 'rejected'
@@ -33,16 +33,16 @@ export interface ToolCallState {
id: string
name: string
status: ToolCallStatus
params?: Record<string, any>
params?: Record<string, unknown>
result?: ToolCallResult
error?: string
startTime?: number
endTime?: number
}
export interface ToolCallResult {
export interface ToolCallResult<T = unknown> {
success: boolean
output?: any
output?: T
error?: string
}
@@ -73,6 +73,14 @@ export interface StreamingContext {
errors: string[]
}
export interface FileAttachment {
id: string
key: string
name: string
mimeType: string
size: number
}
export interface OrchestratorRequest {
message: string
workflowId: string
@@ -82,7 +90,7 @@ export interface OrchestratorRequest {
model?: string
conversationId?: string
contexts?: Array<{ type: string; content: string }>
fileAttachments?: any[]
fileAttachments?: FileAttachment[]
commands?: string[]
provider?: CopilotProviderConfig
streamToolCalls?: boolean
@@ -116,8 +124,8 @@ export interface ToolCallSummary {
id: string
name: string
status: ToolCallStatus
params?: Record<string, any>
result?: any
params?: Record<string, unknown>
result?: unknown
error?: string
durationMs?: number
}

View File

@@ -147,67 +147,13 @@ export class BaseClientTool {
}
/**
* Mark a tool as complete on the server (proxies to server-side route).
* Once called, the tool is considered complete and won't be marked again.
* Mark a tool as complete. Tool completion is now handled server-side by the
* orchestrator (which calls the Go backend directly). Client tools are retained
* for UI display only — this method just tracks local state.
*/
async markToolComplete(status: number, message?: any, data?: any): Promise<boolean> {
// Prevent double-marking
if (this.isMarkedComplete) {
baseToolLogger.warn('markToolComplete called but tool already marked complete', {
toolCallId: this.toolCallId,
toolName: this.name,
existingState: this.state,
attemptedStatus: status,
})
return true
}
async markToolComplete(_status: number, _message?: unknown, _data?: unknown): Promise<boolean> {
this.isMarkedComplete = true
try {
baseToolLogger.info('markToolComplete called', {
toolCallId: this.toolCallId,
toolName: this.name,
state: this.state,
status,
hasMessage: message !== undefined,
hasData: data !== undefined,
})
} catch {}
try {
const res = await fetch('/api/copilot/tools/mark-complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: this.toolCallId,
name: this.name,
status,
message,
data,
}),
})
if (!res.ok) {
// Try to surface server error
let errorText = `Failed to mark tool complete (status ${res.status})`
try {
const { error } = await res.json()
if (error) errorText = String(error)
} catch {}
throw new Error(errorText)
}
const json = (await res.json()) as { success?: boolean }
return json?.success === true
} catch (e) {
// Default failure path - but tool is still marked complete locally
baseToolLogger.error('Failed to mark tool complete on server', {
toolCallId: this.toolCallId,
error: e instanceof Error ? e.message : String(e),
})
return false
}
return true
}
// Accept (continue) for interrupt flows: move pending -> executing

View File

@@ -1,23 +1,11 @@
import { createLogger } from '@sim/logger'
import { FileCode, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import {
ExecuteResponseSuccessSchema,
GetBlockConfigInput,
GetBlockConfigResult,
} from '@/lib/copilot/tools/shared/schemas'
import { getLatestBlock } from '@/blocks/registry'
interface GetBlockConfigArgs {
blockType: string
operation?: string
trigger?: boolean
}
export class GetBlockConfigClientTool extends BaseClientTool {
static readonly id = 'get_block_config'
@@ -63,38 +51,9 @@ export class GetBlockConfigClientTool extends BaseClientTool {
},
}
async execute(args?: GetBlockConfigArgs): Promise<void> {
const logger = createLogger('GetBlockConfigClientTool')
try {
this.setState(ClientToolCallState.executing)
const { blockType, operation, trigger } = GetBlockConfigInput.parse(args || {})
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
toolName: 'get_block_config',
payload: { blockType, operation, trigger },
}),
})
if (!res.ok) {
const errorText = await res.text().catch(() => '')
throw new Error(errorText || `Server error (${res.status})`)
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
const result = GetBlockConfigResult.parse(parsed.result)
const inputCount = Object.keys(result.inputs).length
const outputCount = Object.keys(result.outputs).length
await this.markToolComplete(200, { inputs: inputCount, outputs: outputCount }, result)
this.setState(ClientToolCallState.success)
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
logger.error('Execute failed', { message })
await this.markToolComplete(500, message)
this.setState(ClientToolCallState.error)
}
async execute(): Promise<void> {
// Tool execution is handled server-side by the orchestrator.
// Client tool classes are retained for UI display configuration only.
this.setState(ClientToolCallState.success)
}
}

View File

@@ -1,21 +1,11 @@
import { createLogger } from '@sim/logger'
import { ListFilter, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import {
ExecuteResponseSuccessSchema,
GetBlockOptionsInput,
GetBlockOptionsResult,
} from '@/lib/copilot/tools/shared/schemas'
import { getLatestBlock } from '@/blocks/registry'
interface GetBlockOptionsArgs {
blockId: string
}
export class GetBlockOptionsClientTool extends BaseClientTool {
static readonly id = 'get_block_options'
@@ -65,46 +55,9 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
},
}
async execute(args?: GetBlockOptionsArgs): Promise<void> {
const logger = createLogger('GetBlockOptionsClientTool')
try {
this.setState(ClientToolCallState.executing)
// Handle both camelCase and snake_case parameter names, plus blockType as an alias
const normalizedArgs = args
? {
blockId:
args.blockId ||
(args as any).block_id ||
(args as any).blockType ||
(args as any).block_type,
}
: {}
logger.info('execute called', { originalArgs: args, normalizedArgs })
const { blockId } = GetBlockOptionsInput.parse(normalizedArgs)
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'get_block_options', payload: { blockId } }),
})
if (!res.ok) {
const errorText = await res.text().catch(() => '')
throw new Error(errorText || `Server error (${res.status})`)
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
const result = GetBlockOptionsResult.parse(parsed.result)
await this.markToolComplete(200, { operations: result.operations.length }, result)
this.setState(ClientToolCallState.success)
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
logger.error('Execute failed', { message })
await this.markToolComplete(500, message)
this.setState(ClientToolCallState.error)
}
async execute(): Promise<void> {
// Tool execution is handled server-side by the orchestrator.
// Client tool classes are retained for UI display configuration only.
this.setState(ClientToolCallState.success)
}
}

View File

@@ -1,14 +1,9 @@
import { createLogger } from '@sim/logger'
import { Blocks, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import {
ExecuteResponseSuccessSchema,
GetBlocksAndToolsResult,
} from '@/lib/copilot/tools/shared/schemas'
export class GetBlocksAndToolsClientTool extends BaseClientTool {
static readonly id = 'get_blocks_and_tools'
@@ -31,30 +26,8 @@ export class GetBlocksAndToolsClientTool extends BaseClientTool {
}
async execute(): Promise<void> {
const logger = createLogger('GetBlocksAndToolsClientTool')
try {
this.setState(ClientToolCallState.executing)
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'get_blocks_and_tools', payload: {} }),
})
if (!res.ok) {
const errorText = await res.text().catch(() => '')
throw new Error(errorText || `Server error (${res.status})`)
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
const result = GetBlocksAndToolsResult.parse(parsed.result)
// TODO: Temporarily sending empty data to test 403 issue
await this.markToolComplete(200, 'Successfully retrieved blocks and tools', {})
this.setState(ClientToolCallState.success)
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
await this.markToolComplete(500, message)
this.setState(ClientToolCallState.error)
}
// Tool execution is handled server-side by the orchestrator.
// Client tool classes are retained for UI display configuration only.
this.setState(ClientToolCallState.success)
}
}

View File

@@ -1,19 +1,9 @@
import { createLogger } from '@sim/logger'
import { ListFilter, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import {
ExecuteResponseSuccessSchema,
GetBlocksMetadataInput,
GetBlocksMetadataResult,
} from '@/lib/copilot/tools/shared/schemas'
interface GetBlocksMetadataArgs {
blockIds: string[]
}
export class GetBlocksMetadataClientTool extends BaseClientTool {
static readonly id = 'get_blocks_metadata'
@@ -63,33 +53,9 @@ export class GetBlocksMetadataClientTool extends BaseClientTool {
},
}
async execute(args?: GetBlocksMetadataArgs): Promise<void> {
const logger = createLogger('GetBlocksMetadataClientTool')
try {
this.setState(ClientToolCallState.executing)
const { blockIds } = GetBlocksMetadataInput.parse(args || {})
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'get_blocks_metadata', payload: { blockIds } }),
})
if (!res.ok) {
const errorText = await res.text().catch(() => '')
throw new Error(errorText || `Server error (${res.status})`)
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
const result = GetBlocksMetadataResult.parse(parsed.result)
await this.markToolComplete(200, { retrieved: Object.keys(result.metadata).length }, result)
this.setState(ClientToolCallState.success)
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
logger.error('Execute failed', { message })
await this.markToolComplete(500, message)
this.setState(ClientToolCallState.error)
}
async execute(): Promise<void> {
// Tool execution is handled server-side by the orchestrator.
// Client tool classes are retained for UI display configuration only.
this.setState(ClientToolCallState.success)
}
}

View File

@@ -1,14 +1,9 @@
import { createLogger } from '@sim/logger'
import { ListFilter, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import {
ExecuteResponseSuccessSchema,
GetTriggerBlocksResult,
} from '@/lib/copilot/tools/shared/schemas'
export class GetTriggerBlocksClientTool extends BaseClientTool {
static readonly id = 'get_trigger_blocks'
@@ -31,34 +26,8 @@ export class GetTriggerBlocksClientTool extends BaseClientTool {
}
async execute(): Promise<void> {
const logger = createLogger('GetTriggerBlocksClientTool')
try {
this.setState(ClientToolCallState.executing)
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'get_trigger_blocks', payload: {} }),
})
if (!res.ok) {
const errorText = await res.text().catch(() => '')
try {
const errorJson = JSON.parse(errorText)
throw new Error(errorJson.error || errorText || `Server error (${res.status})`)
} catch {
throw new Error(errorText || `Server error (${res.status})`)
}
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
const result = GetTriggerBlocksResult.parse(parsed.result)
await this.markToolComplete(200, 'Successfully retrieved trigger blocks', result)
this.setState(ClientToolCallState.success)
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
await this.markToolComplete(500, message)
this.setState(ClientToolCallState.error)
}
// Tool execution is handled server-side by the orchestrator.
// Client tool classes are retained for UI display configuration only.
this.setState(ClientToolCallState.success)
}
}

View File

@@ -1,16 +1,11 @@
import { createLogger } from '@sim/logger'
import { Database, Loader2, MinusCircle, PlusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import {
ExecuteResponseSuccessSchema,
type KnowledgeBaseArgs,
} from '@/lib/copilot/tools/shared/schemas'
import { type KnowledgeBaseArgs } from '@/lib/copilot/tools/shared/schemas'
import { useCopilotStore } from '@/stores/panel/copilot/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
/**
* Client tool for knowledge base operations
@@ -99,45 +94,9 @@ export class KnowledgeBaseClientTool extends BaseClientTool {
await this.execute(args)
}
async execute(args?: KnowledgeBaseArgs): Promise<void> {
const logger = createLogger('KnowledgeBaseClientTool')
try {
this.setState(ClientToolCallState.executing)
// Get the workspace ID from the workflow registry hydration state
const { hydration } = useWorkflowRegistry.getState()
const workspaceId = hydration.workspaceId
// Build payload with workspace ID included in args
const payload: KnowledgeBaseArgs = {
...(args || { operation: 'list' }),
args: {
...(args?.args || {}),
workspaceId: workspaceId || undefined,
},
}
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'knowledge_base', payload }),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(txt || `Server error (${res.status})`)
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'Knowledge base operation completed', parsed.result)
this.setState(ClientToolCallState.success)
} catch (e: any) {
logger.error('execute failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to access knowledge base')
}
async execute(): Promise<void> {
// Tool execution is handled server-side by the orchestrator.
// Client tool classes are retained for UI display configuration only.
this.setState(ClientToolCallState.success)
}
}

View File

@@ -93,33 +93,15 @@ export class MakeApiRequestClientTool extends BaseClientTool {
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: MakeApiRequestArgs): Promise<void> {
const logger = createLogger('MakeApiRequestClientTool')
try {
this.setState(ClientToolCallState.executing)
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'make_api_request', payload: args || {} }),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(txt || `Server error (${res.status})`)
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'API request executed', parsed.result)
this.setState(ClientToolCallState.success)
} catch (e: any) {
logger.error('execute failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'API request failed')
}
async handleAccept(_args?: MakeApiRequestArgs): Promise<void> {
// Tool execution is handled server-side by the orchestrator.
this.setState(ClientToolCallState.executing)
}
async execute(args?: MakeApiRequestArgs): Promise<void> {
await this.handleAccept(args)
async execute(): Promise<void> {
// Tool execution is handled server-side by the orchestrator.
// Client tool classes are retained for UI display configuration only.
this.setState(ClientToolCallState.success)
}
}

View File

@@ -1,17 +1,9 @@
import { createLogger } from '@sim/logger'
import { BookOpen, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
interface SearchDocumentationArgs {
query: string
topK?: number
threshold?: number
}
export class SearchDocumentationClientTool extends BaseClientTool {
static readonly id = 'search_documentation'
@@ -53,28 +45,9 @@ export class SearchDocumentationClientTool extends BaseClientTool {
},
}
async execute(args?: SearchDocumentationArgs): Promise<void> {
const logger = createLogger('SearchDocumentationClientTool')
try {
this.setState(ClientToolCallState.executing)
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'search_documentation', payload: args || {} }),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(txt || `Server error (${res.status})`)
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'Documentation search complete', parsed.result)
this.setState(ClientToolCallState.success)
} catch (e: any) {
logger.error('execute failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Documentation search failed')
}
async execute(): Promise<void> {
// Tool execution is handled server-side by the orchestrator.
// Client tool classes are retained for UI display configuration only.
this.setState(ClientToolCallState.success)
}
}

View File

@@ -1,17 +1,9 @@
import { createLogger } from '@sim/logger'
import { Key, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface GetCredentialsArgs {
userId?: string
workflowId?: string
}
export class GetCredentialsClientTool extends BaseClientTool {
static readonly id = 'get_credentials'
@@ -41,33 +33,9 @@ export class GetCredentialsClientTool extends BaseClientTool {
},
}
async execute(args?: GetCredentialsArgs): Promise<void> {
const logger = createLogger('GetCredentialsClientTool')
try {
this.setState(ClientToolCallState.executing)
const payload: GetCredentialsArgs = { ...(args || {}) }
if (!payload.workflowId && !payload.userId) {
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (activeWorkflowId) payload.workflowId = activeWorkflowId
}
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'get_credentials', payload }),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(txt || `Server error (${res.status})`)
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'Connected integrations fetched', parsed.result)
this.setState(ClientToolCallState.success)
} catch (e: any) {
logger.error('execute failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to fetch connected integrations')
}
async execute(): Promise<void> {
// Tool execution is handled server-side by the orchestrator.
// Client tool classes are retained for UI display configuration only.
this.setState(ClientToolCallState.success)
}
}

View File

@@ -107,46 +107,15 @@ export class SetEnvironmentVariablesClientTool extends BaseClientTool {
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: SetEnvArgs): Promise<void> {
const logger = createLogger('SetEnvironmentVariablesClientTool')
try {
this.setState(ClientToolCallState.executing)
const payload: SetEnvArgs = { ...(args || { variables: {} }) }
if (!payload.workflowId) {
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (activeWorkflowId) payload.workflowId = activeWorkflowId
}
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'set_environment_variables', payload }),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(txt || `Server error (${res.status})`)
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'Environment variables updated', parsed.result)
this.setState(ClientToolCallState.success)
// Refresh the environment store so the UI reflects the new variables
try {
await useEnvironmentStore.getState().loadEnvironmentVariables()
logger.info('Environment store refreshed after setting variables')
} catch (error) {
logger.warn('Failed to refresh environment store:', error)
}
} catch (e: any) {
logger.error('execute failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to set environment variables')
}
async handleAccept(_args?: SetEnvArgs): Promise<void> {
// Tool execution is handled server-side by the orchestrator.
this.setState(ClientToolCallState.executing)
}
async execute(args?: SetEnvArgs): Promise<void> {
await this.handleAccept(args)
async execute(): Promise<void> {
// Tool execution is handled server-side by the orchestrator.
// Client tool classes are retained for UI display configuration only.
this.setState(ClientToolCallState.success)
}
}

View File

@@ -1,18 +1,9 @@
import { createLogger } from '@sim/logger'
import { Loader2, MinusCircle, TerminalSquare, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface GetWorkflowConsoleArgs {
workflowId?: string
limit?: number
includeDetails?: boolean
}
export class GetWorkflowConsoleClientTool extends BaseClientTool {
static readonly id = 'get_workflow_console'
@@ -61,52 +52,9 @@ export class GetWorkflowConsoleClientTool extends BaseClientTool {
},
}
async execute(args?: GetWorkflowConsoleArgs): Promise<void> {
const logger = createLogger('GetWorkflowConsoleClientTool')
try {
this.setState(ClientToolCallState.executing)
const params = args || {}
let workflowId = params.workflowId
if (!workflowId) {
const { activeWorkflowId } = useWorkflowRegistry.getState()
workflowId = activeWorkflowId || undefined
}
if (!workflowId) {
logger.error('No active workflow found for console fetch')
this.setState(ClientToolCallState.error)
await this.markToolComplete(400, 'No active workflow found')
return
}
const payload = {
workflowId,
limit: params.limit ?? 3,
includeDetails: params.includeDetails ?? true,
}
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'get_workflow_console', payload }),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `Server error (${res.status})`)
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
// Mark success and include result data for UI rendering
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'Workflow console fetched', parsed.result)
this.setState(ClientToolCallState.success)
} catch (e: any) {
const message = e instanceof Error ? e.message : String(e)
createLogger('GetWorkflowConsoleClientTool').error('execute failed', { message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, message)
}
async execute(): Promise<void> {
// Tool execution is handled server-side by the orchestrator.
// Client tool classes are retained for UI display configuration only.
this.setState(ClientToolCallState.success)
}
}

View File

@@ -4169,10 +4169,8 @@ export const useCopilotStore = create<CopilotStore>()(
// Credential masking
loadSensitiveCredentialIds: async () => {
try {
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'get_credentials', payload: {} }),
const res = await fetch('/api/copilot/credentials', {
credentials: 'include',
})
if (!res.ok) {
logger.warn('[loadSensitiveCredentialIds] Failed to fetch credentials', {