Tool refactor

This commit is contained in:
Siddharth Ganesan
2026-02-05 13:11:12 -08:00
parent 53d436835a
commit 57cba2ab1e
73 changed files with 2335 additions and 7901 deletions

View File

@@ -6,16 +6,10 @@ import clsx from 'clsx'
import { ChevronUp, LayoutList } from 'lucide-react'
import Editor from 'react-simple-code-editor'
import { Button, Code, getCodeEditorProps, highlight, languages } from '@/components/emcn'
import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool'
import { getClientTool } from '@/lib/copilot/tools/client/manager'
import { getRegisteredTools } from '@/lib/copilot/tools/client/registry'
import '@/lib/copilot/tools/client/init-tool-configs'
import {
getSubagentLabels as getSubagentLabelsFromConfig,
getToolUIConfig,
hasInterrupt as hasInterruptFromConfig,
isSpecialTool as isSpecialToolFromConfig,
} from '@/lib/copilot/tools/client/ui-config'
ClientToolCallState,
TOOL_DISPLAY_REGISTRY,
} from '@/lib/copilot/tools/client/tool-display-registry'
import { formatDuration } from '@/lib/core/utils/formatting'
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
@@ -26,7 +20,6 @@ import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/co
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 type { SubAgentContentBlock } from '@/stores/panel/copilot/types'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -711,8 +704,8 @@ const ShimmerOverlayText = memo(function ShimmerOverlayText({
* @returns The completion label from UI config, defaults to 'Thought'
*/
function getSubagentCompletionLabel(toolName: string): string {
const labels = getSubagentLabelsFromConfig(toolName, false)
return labels?.completed ?? 'Thought'
const labels = TOOL_DISPLAY_REGISTRY[toolName]?.uiConfig?.subagentLabels
return labels?.completed || 'Thought'
}
/**
@@ -944,7 +937,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
* Determines if a tool call should display with special gradient styling.
*/
function isSpecialToolCall(toolCall: CopilotToolCall): boolean {
return isSpecialToolFromConfig(toolCall.name)
return TOOL_DISPLAY_REGISTRY[toolCall.name]?.uiConfig?.isSpecial === true
}
/**
@@ -1224,28 +1217,11 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
/** Checks if a tool is server-side executed (not a client tool) */
function isIntegrationTool(toolName: string): boolean {
return !CLASS_TOOL_METADATA[toolName]
return !TOOL_DISPLAY_REGISTRY[toolName]
}
function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean {
if (hasInterruptFromConfig(toolCall.name) && toolCall.state === 'pending') {
return true
}
const instance = getClientTool(toolCall.id)
let hasInterrupt = !!instance?.getInterruptDisplays?.()
if (!hasInterrupt) {
try {
const def = getRegisteredTools()[toolCall.name]
if (def) {
hasInterrupt =
typeof def.hasInterrupt === 'function'
? !!def.hasInterrupt(toolCall.params || {})
: !!def.hasInterrupt
}
} catch {}
}
const hasInterrupt = TOOL_DISPLAY_REGISTRY[toolCall.name]?.uiConfig?.interrupt === true
if (hasInterrupt && toolCall.state === 'pending') {
return true
}
@@ -1299,11 +1275,9 @@ async function handleSkip(toolCall: CopilotToolCall, setToolCallState: any, onSt
function getDisplayName(toolCall: CopilotToolCall): string {
const fromStore = (toolCall as any).display?.text
if (fromStore) return fromStore
try {
const def = getRegisteredTools()[toolCall.name] as any
const byState = def?.metadata?.displayNames?.[toolCall.state]
if (byState?.text) return byState.text
} catch {}
const registryEntry = TOOL_DISPLAY_REGISTRY[toolCall.name]
const byState = registryEntry?.displayNames?.[toolCall.state as ClientToolCallState]
if (byState?.text) return byState.text
const stateVerb = getStateVerb(toolCall.state)
const formattedName = formatToolName(toolCall.name)
@@ -1481,23 +1455,7 @@ export function ToolCall({
return null
// Special rendering for subagent tools - show as thinking text with tool calls at top level
const SUBAGENT_TOOLS = [
'plan',
'edit',
'debug',
'test',
'deploy',
'evaluate',
'auth',
'research',
'knowledge',
'custom_tool',
'tour',
'info',
'workflow',
'superagent',
]
const isSubagentTool = SUBAGENT_TOOLS.includes(toolCall.name)
const isSubagentTool = TOOL_DISPLAY_REGISTRY[toolCall.name]?.uiConfig?.subagent === true
// For ALL subagent tools, don't show anything until we have blocks with content
if (isSubagentTool) {
@@ -1537,17 +1495,18 @@ export function ToolCall({
stateStr === 'aborted'
// Allow rendering if:
// 1. Tool is in CLASS_TOOL_METADATA (client tools), OR
// 1. Tool is in TOOL_DISPLAY_REGISTRY (client tools), OR
// 2. We're in build mode (integration tools are executed server-side), OR
// 3. Tool call is already completed (historical - should always render)
const isClientTool = !!CLASS_TOOL_METADATA[toolCall.name]
const isClientTool = !!TOOL_DISPLAY_REGISTRY[toolCall.name]
const isIntegrationToolInBuildMode = mode === 'build' && !isClientTool
if (!isClientTool && !isIntegrationToolInBuildMode && !isCompletedToolCall) {
return null
}
const toolUIConfig = TOOL_DISPLAY_REGISTRY[toolCall.name]?.uiConfig
// Check if tool has params table config (meaning it's expandable)
const hasParamsTable = !!getToolUIConfig(toolCall.name)?.paramsTable
const hasParamsTable = !!toolUIConfig?.paramsTable
const isRunWorkflow = toolCall.name === 'run_workflow'
const isExpandableTool =
hasParamsTable ||
@@ -1557,7 +1516,6 @@ export function ToolCall({
const showButtons = isCurrentMessage && shouldShowRunSkipButtons(toolCall)
// Check UI config for secondary action - only show for current message tool calls
const toolUIConfig = getToolUIConfig(toolCall.name)
const secondaryAction = toolUIConfig?.secondaryAction
const showSecondaryAction = secondaryAction?.showInStates.includes(
toolCall.state as ClientToolCallState

View File

@@ -18,7 +18,7 @@ import 'reactflow/dist/style.css'
import { createLogger } from '@sim/logger'
import { useShallow } from 'zustand/react/shallow'
import { useSession } from '@/lib/auth/auth-client'
import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/other/oauth-request-access'
import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/base-tool'
import type { OAuthProvider } from '@/lib/oauth'
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'

View File

@@ -1,120 +0,0 @@
/**
* Base class for subagent tools.
*
* Subagent tools spawn a server-side subagent that does the actual work.
* The tool auto-executes and the subagent's output is streamed back
* as nested content under the tool call.
*
* Examples: edit, plan, debug, evaluate, research, etc.
*/
import type { LucideIcon } from 'lucide-react'
import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState } from './base-tool'
import type { SubagentConfig, ToolUIConfig } from './ui-config'
import { registerToolUIConfig } from './ui-config'
/**
* Configuration for creating a subagent tool
*/
export interface SubagentToolConfig {
/** Unique tool ID */
id: string
/** Display names per state */
displayNames: {
streaming: { text: string; icon: LucideIcon }
success: { text: string; icon: LucideIcon }
error: { text: string; icon: LucideIcon }
}
/** Subagent UI configuration */
subagent: SubagentConfig
/**
* Optional: Whether this is a "special" tool (gets gradient styling).
* Default: false
*/
isSpecial?: boolean
}
/**
* Create metadata for a subagent tool from config
*/
function createSubagentMetadata(config: SubagentToolConfig): BaseClientToolMetadata {
const { displayNames, subagent, isSpecial } = config
const { streaming, success, error } = displayNames
const uiConfig: ToolUIConfig = {
isSpecial: isSpecial ?? false,
subagent,
}
return {
displayNames: {
[ClientToolCallState.generating]: streaming,
[ClientToolCallState.pending]: streaming,
[ClientToolCallState.executing]: streaming,
[ClientToolCallState.success]: success,
[ClientToolCallState.error]: error,
[ClientToolCallState.rejected]: {
text: `${config.id.charAt(0).toUpperCase() + config.id.slice(1)} skipped`,
icon: error.icon,
},
[ClientToolCallState.aborted]: {
text: `${config.id.charAt(0).toUpperCase() + config.id.slice(1)} aborted`,
icon: error.icon,
},
},
uiConfig,
}
}
/**
* Base class for subagent tools.
* Extends BaseClientTool with subagent-specific behavior.
*/
export abstract class BaseSubagentTool extends BaseClientTool {
/**
* Subagent configuration.
* Override in subclasses to customize behavior.
*/
static readonly subagentConfig: SubagentToolConfig
constructor(toolCallId: string, config: SubagentToolConfig) {
super(toolCallId, config.id, createSubagentMetadata(config))
// Register UI config for this tool
registerToolUIConfig(config.id, this.metadata.uiConfig!)
}
/**
* Execute the subagent tool.
* Immediately transitions to executing state - the actual work
* is done server-side by the subagent.
*/
async execute(_args?: Record<string, any>): Promise<void> {
this.setState(ClientToolCallState.executing)
// The tool result will come from the server via tool_result event
// when the subagent completes its work
}
}
/**
* Factory function to create a subagent tool class.
* Use this for simple subagent tools that don't need custom behavior.
*/
export function createSubagentToolClass(config: SubagentToolConfig) {
// Register UI config at class creation time
const uiConfig: ToolUIConfig = {
isSpecial: config.isSpecial ?? false,
subagent: config.subagent,
}
registerToolUIConfig(config.id, uiConfig)
return class extends BaseClientTool {
static readonly id = config.id
constructor(toolCallId: string) {
super(toolCallId, config.id, createSubagentMetadata(config))
}
async execute(_args?: Record<string, any>): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
}

View File

@@ -1,15 +1,5 @@
// Lazy require in setState to avoid circular init issues
import { createLogger } from '@sim/logger'
import type { LucideIcon } from 'lucide-react'
import type { ToolUIConfig } from './ui-config'
const baseToolLogger = createLogger('BaseClientTool')
const DEFAULT_TOOL_TIMEOUT_MS = 5 * 60 * 1000
export const WORKFLOW_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000
// Client tool call states used by the new runtime
export enum ClientToolCallState {
generating = 'generating',
pending = 'pending',
@@ -22,198 +12,29 @@ export enum ClientToolCallState {
background = 'background',
}
// Display configuration for a given state
export interface ClientToolDisplay {
text: string
icon: LucideIcon
}
/**
* Function to generate dynamic display text based on tool parameters and state
* @param params - The tool call parameters
* @param state - The current tool call state
* @returns The dynamic text to display, or undefined to use the default text
*/
export interface BaseClientToolMetadata {
displayNames: Partial<Record<ClientToolCallState, ClientToolDisplay>>
uiConfig?: Record<string, unknown>
getDynamicText?: (params: Record<string, unknown>, state: ClientToolCallState) => string | undefined
}
export type DynamicTextFormatter = (
params: Record<string, any>,
params: Record<string, unknown>,
state: ClientToolCallState
) => string | undefined
export interface BaseClientToolMetadata {
displayNames: Partial<Record<ClientToolCallState, ClientToolDisplay>>
interrupt?: {
accept: ClientToolDisplay
reject: ClientToolDisplay
}
/**
* Optional function to generate dynamic display text based on parameters
* If provided, this will override the default text in displayNames
*/
getDynamicText?: DynamicTextFormatter
/**
* UI configuration for how this tool renders in the tool-call component.
* This replaces hardcoded logic in tool-call.tsx with declarative config.
*/
uiConfig?: ToolUIConfig
}
export class BaseClientTool {
readonly toolCallId: string
readonly name: string
protected state: ClientToolCallState
protected metadata: BaseClientToolMetadata
protected isMarkedComplete = false
protected timeoutMs: number = DEFAULT_TOOL_TIMEOUT_MS
constructor(toolCallId: string, name: string, metadata: BaseClientToolMetadata) {
this.toolCallId = toolCallId
this.name = name
this.metadata = metadata
this.state = ClientToolCallState.generating
}
/**
* Set a custom timeout for this tool (in milliseconds)
*/
setTimeoutMs(ms: number): void {
this.timeoutMs = ms
}
/**
* Check if this tool has been marked complete
*/
hasBeenMarkedComplete(): boolean {
return this.isMarkedComplete
}
/**
* Ensure the tool is marked complete. If not already marked, marks it with error.
* This should be called in finally blocks to prevent leaked tool calls.
*/
async ensureMarkedComplete(
fallbackMessage = 'Tool execution did not complete properly'
): Promise<void> {
if (!this.isMarkedComplete) {
baseToolLogger.warn('Tool was not marked complete, marking with error', {
toolCallId: this.toolCallId,
toolName: this.name,
state: this.state,
})
await this.markToolComplete(500, fallbackMessage)
this.setState(ClientToolCallState.error)
}
}
/**
* Execute with timeout protection. Wraps the execution in a timeout and ensures
* markToolComplete is always called.
*/
async executeWithTimeout(executeFn: () => Promise<void>, timeoutMs?: number): Promise<void> {
const timeout = timeoutMs ?? this.timeoutMs
let timeoutId: NodeJS.Timeout | null = null
try {
await Promise.race([
executeFn(),
new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`Tool execution timed out after ${timeout / 1000} seconds`))
}, timeout)
}),
])
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
baseToolLogger.error('Tool execution failed or timed out', {
toolCallId: this.toolCallId,
toolName: this.name,
error: message,
})
// Only mark complete if not already marked
if (!this.isMarkedComplete) {
await this.markToolComplete(500, message)
this.setState(ClientToolCallState.error)
}
} finally {
if (timeoutId) clearTimeout(timeoutId)
// Ensure tool is always marked complete
await this.ensureMarkedComplete()
}
}
// Intentionally left empty - specific tools can override
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async execute(_args?: Record<string, any>): Promise<void> {
return
}
/**
* 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?: unknown, _data?: unknown): Promise<boolean> {
this.isMarkedComplete = true
return true
}
// Accept (continue) for interrupt flows: move pending -> executing
async handleAccept(): Promise<void> {
this.setState(ClientToolCallState.executing)
}
// Reject (skip) for interrupt flows: mark complete with a standard skip message
async handleReject(): Promise<void> {
await this.markToolComplete(200, 'Tool execution was skipped by the user')
this.setState(ClientToolCallState.rejected)
}
// Return the display configuration for the current state
getDisplayState(): ClientToolDisplay | undefined {
return this.metadata.displayNames[this.state]
}
// Return interrupt display config (labels/icons) if defined
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
return this.metadata.interrupt
}
// Transition to a new state (also sync to Copilot store)
setState(next: ClientToolCallState, options?: { result?: any }): void {
const prev = this.state
this.state = next
// Notify store via manager to avoid import cycles
try {
const { syncToolState } = require('@/lib/copilot/tools/client/manager')
syncToolState(this.toolCallId, next, options)
} catch {}
// Log transition after syncing
try {
baseToolLogger.info('setState transition', {
toolCallId: this.toolCallId,
toolName: this.name,
prev,
next,
hasResult: options?.result !== undefined,
})
} catch {}
}
// Expose current state
getState(): ClientToolCallState {
return this.state
}
hasInterrupt(): boolean {
return !!this.metadata.interrupt
}
/**
* Get UI configuration for this tool.
* Used by tool-call component to determine rendering behavior.
*/
getUIConfig(): ToolUIConfig | undefined {
return this.metadata.uiConfig
}
export const WORKFLOW_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000
/** Event detail for OAuth connect events dispatched by the copilot. */
export interface OAuthConnectEventDetail {
providerName: string
serviceId: string
providerId: string
requiredScopes: string[]
newScopes?: string[]
}

View File

@@ -1,59 +0,0 @@
import { FileCode, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { getLatestBlock } from '@/blocks/registry'
export class GetBlockConfigClientTool extends BaseClientTool {
static readonly id = 'get_block_config'
constructor(toolCallId: string) {
super(toolCallId, GetBlockConfigClientTool.id, GetBlockConfigClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Getting block config', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Getting block config', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Getting block config', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Retrieved block config', icon: FileCode },
[ClientToolCallState.error]: { text: 'Failed to get block config', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted getting block config', icon: XCircle },
[ClientToolCallState.rejected]: {
text: 'Skipped getting block config',
icon: MinusCircle,
},
},
getDynamicText: (params, state) => {
if (params?.blockType && typeof params.blockType === 'string') {
const blockConfig = getLatestBlock(params.blockType)
const blockName = (blockConfig?.name ?? params.blockType.replace(/_/g, ' ')).toLowerCase()
const opSuffix = params.operation ? ` (${params.operation})` : ''
switch (state) {
case ClientToolCallState.success:
return `Retrieved ${blockName}${opSuffix} config`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Retrieving ${blockName}${opSuffix} config`
case ClientToolCallState.error:
return `Failed to retrieve ${blockName}${opSuffix} config`
case ClientToolCallState.aborted:
return `Aborted retrieving ${blockName}${opSuffix} config`
case ClientToolCallState.rejected:
return `Skipped retrieving ${blockName}${opSuffix} config`
}
}
return undefined
},
}
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,63 +0,0 @@
import { ListFilter, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { getLatestBlock } from '@/blocks/registry'
export class GetBlockOptionsClientTool extends BaseClientTool {
static readonly id = 'get_block_options'
constructor(toolCallId: string) {
super(toolCallId, GetBlockOptionsClientTool.id, GetBlockOptionsClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Getting block operations', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Getting block operations', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Getting block operations', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Retrieved block operations', icon: ListFilter },
[ClientToolCallState.error]: { text: 'Failed to get block operations', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted getting block operations', icon: XCircle },
[ClientToolCallState.rejected]: {
text: 'Skipped getting block operations',
icon: MinusCircle,
},
},
getDynamicText: (params, state) => {
const blockId =
(params as any)?.blockId ||
(params as any)?.blockType ||
(params as any)?.block_id ||
(params as any)?.block_type
if (typeof blockId === 'string') {
const blockConfig = getLatestBlock(blockId)
const blockName = (blockConfig?.name ?? blockId.replace(/_/g, ' ')).toLowerCase()
switch (state) {
case ClientToolCallState.success:
return `Retrieved ${blockName} operations`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Retrieving ${blockName} operations`
case ClientToolCallState.error:
return `Failed to retrieve ${blockName} operations`
case ClientToolCallState.aborted:
return `Aborted retrieving ${blockName} operations`
case ClientToolCallState.rejected:
return `Skipped retrieving ${blockName} operations`
}
}
return undefined
},
}
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,33 +0,0 @@
import { Blocks, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class GetBlocksAndToolsClientTool extends BaseClientTool {
static readonly id = 'get_blocks_and_tools'
constructor(toolCallId: string) {
super(toolCallId, GetBlocksAndToolsClientTool.id, GetBlocksAndToolsClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Exploring available options', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Exploring available options', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Exploring available options', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Explored available options', icon: Blocks },
[ClientToolCallState.error]: { text: 'Failed to explore options', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted exploring options', icon: MinusCircle },
[ClientToolCallState.rejected]: { text: 'Skipped exploring options', icon: MinusCircle },
},
interrupt: undefined,
}
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,61 +0,0 @@
import { ListFilter, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class GetBlocksMetadataClientTool extends BaseClientTool {
static readonly id = 'get_blocks_metadata'
constructor(toolCallId: string) {
super(toolCallId, GetBlocksMetadataClientTool.id, GetBlocksMetadataClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Searching block choices', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Searching block choices', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Searching block choices', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Searched block choices', icon: ListFilter },
[ClientToolCallState.error]: { text: 'Failed to search block choices', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted searching block choices', icon: XCircle },
[ClientToolCallState.rejected]: {
text: 'Skipped searching block choices',
icon: MinusCircle,
},
},
getDynamicText: (params, state) => {
if (params?.blockIds && Array.isArray(params.blockIds) && params.blockIds.length > 0) {
const blockList = params.blockIds
.slice(0, 3)
.map((blockId) => blockId.replace(/_/g, ' '))
.join(', ')
const more = params.blockIds.length > 3 ? '...' : ''
const blocks = `${blockList}${more}`
switch (state) {
case ClientToolCallState.success:
return `Searched ${blocks}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Searching ${blocks}`
case ClientToolCallState.error:
return `Failed to search ${blocks}`
case ClientToolCallState.aborted:
return `Aborted searching ${blocks}`
case ClientToolCallState.rejected:
return `Skipped searching ${blocks}`
}
}
return undefined
},
}
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,33 +0,0 @@
import { ListFilter, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class GetTriggerBlocksClientTool extends BaseClientTool {
static readonly id = 'get_trigger_blocks'
constructor(toolCallId: string) {
super(toolCallId, GetTriggerBlocksClientTool.id, GetTriggerBlocksClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Finding trigger blocks', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Finding trigger blocks', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Finding trigger blocks', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Found trigger blocks', icon: ListFilter },
[ClientToolCallState.error]: { text: 'Failed to find trigger blocks', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted finding trigger blocks', icon: MinusCircle },
[ClientToolCallState.rejected]: { text: 'Skipped finding trigger blocks', icon: MinusCircle },
},
interrupt: undefined,
}
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,52 +0,0 @@
import { Loader2, MinusCircle, Search, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class GetExamplesRagClientTool extends BaseClientTool {
static readonly id = 'get_examples_rag'
constructor(toolCallId: string) {
super(toolCallId, GetExamplesRagClientTool.id, GetExamplesRagClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Fetching examples', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Fetching examples', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Fetching examples', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Fetched examples', icon: Search },
[ClientToolCallState.error]: { text: 'Failed to fetch examples', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted getting examples', icon: MinusCircle },
[ClientToolCallState.rejected]: { text: 'Skipped getting examples', icon: MinusCircle },
},
interrupt: undefined,
getDynamicText: (params, state) => {
if (params?.query && typeof params.query === 'string') {
const query = params.query
switch (state) {
case ClientToolCallState.success:
return `Found examples for ${query}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Searching examples for ${query}`
case ClientToolCallState.error:
return `Failed to find examples for ${query}`
case ClientToolCallState.aborted:
return `Aborted searching examples for ${query}`
case ClientToolCallState.rejected:
return `Skipped searching examples for ${query}`
}
}
return undefined
},
}
async execute(): Promise<void> {
return
}
}

View File

@@ -1,58 +0,0 @@
import { Loader2, MinusCircle, XCircle, Zap } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class GetOperationsExamplesClientTool extends BaseClientTool {
static readonly id = 'get_operations_examples'
constructor(toolCallId: string) {
super(toolCallId, GetOperationsExamplesClientTool.id, GetOperationsExamplesClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Designing workflow component', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Designing workflow component', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Designing workflow component', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Designed workflow component', icon: Zap },
[ClientToolCallState.error]: { text: 'Failed to design workflow component', icon: XCircle },
[ClientToolCallState.aborted]: {
text: 'Aborted designing workflow component',
icon: MinusCircle,
},
[ClientToolCallState.rejected]: {
text: 'Skipped designing workflow component',
icon: MinusCircle,
},
},
interrupt: undefined,
getDynamicText: (params, state) => {
if (params?.query && typeof params.query === 'string') {
const query = params.query
switch (state) {
case ClientToolCallState.success:
return `Designed ${query}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Designing ${query}`
case ClientToolCallState.error:
return `Failed to design ${query}`
case ClientToolCallState.aborted:
return `Aborted designing ${query}`
case ClientToolCallState.rejected:
return `Skipped designing ${query}`
}
}
return undefined
},
}
async execute(): Promise<void> {
return
}
}

View File

@@ -1,31 +0,0 @@
import { Loader2, MinusCircle, XCircle, Zap } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class GetTriggerExamplesClientTool extends BaseClientTool {
static readonly id = 'get_trigger_examples'
constructor(toolCallId: string) {
super(toolCallId, GetTriggerExamplesClientTool.id, GetTriggerExamplesClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Selecting a trigger', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Selecting a trigger', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Selecting a trigger', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Selected a trigger', icon: Zap },
[ClientToolCallState.error]: { text: 'Failed to select a trigger', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted selecting a trigger', icon: MinusCircle },
[ClientToolCallState.rejected]: { text: 'Skipped selecting a trigger', icon: MinusCircle },
},
interrupt: undefined,
}
async execute(): Promise<void> {
return
}
}

View File

@@ -1,37 +0,0 @@
import { Loader2, MinusCircle, PencilLine, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class SummarizeClientTool extends BaseClientTool {
static readonly id = 'summarize_conversation'
constructor(toolCallId: string) {
super(toolCallId, SummarizeClientTool.id, SummarizeClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Summarizing conversation', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Summarizing conversation', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Summarizing conversation', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Summarized conversation', icon: PencilLine },
[ClientToolCallState.error]: { text: 'Failed to summarize conversation', icon: XCircle },
[ClientToolCallState.aborted]: {
text: 'Aborted summarizing conversation',
icon: MinusCircle,
},
[ClientToolCallState.rejected]: {
text: 'Skipped summarizing conversation',
icon: MinusCircle,
},
},
interrupt: undefined,
}
async execute(): Promise<void> {
return
}
}

View File

@@ -1,36 +0,0 @@
/**
* Initialize all tool UI configurations.
*
* This module imports all client tools to trigger their UI config registration.
* Import this module early in the app to ensure all tool configs are available.
*/
// Other tools (subagents)
import './other/auth'
import './other/custom-tool'
import './other/debug'
import './other/deploy'
import './other/edit'
import './other/evaluate'
import './other/info'
import './other/knowledge'
import './other/make-api-request'
import './other/plan'
import './other/research'
import './other/sleep'
import './other/superagent'
import './other/test'
import './other/tour'
import './other/workflow'
// Workflow tools
import './workflow/deploy-api'
import './workflow/deploy-chat'
import './workflow/deploy-mcp'
import './workflow/edit-workflow'
import './workflow/redeploy'
import './workflow/run-workflow'
import './workflow/set-global-workflow-variables'
// User tools
import './user/set-environment-variables'

View File

@@ -1,102 +0,0 @@
import { Database, Loader2, MinusCircle, PlusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { type KnowledgeBaseArgs } from '@/lib/copilot/tools/shared/schemas'
import { useCopilotStore } from '@/stores/panel/copilot/store'
/**
* Client tool for knowledge base operations
*/
export class KnowledgeBaseClientTool extends BaseClientTool {
static readonly id = 'knowledge_base'
constructor(toolCallId: string) {
super(toolCallId, KnowledgeBaseClientTool.id, KnowledgeBaseClientTool.metadata)
}
/**
* Only show interrupt for create operation
*/
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
const toolCallsById = useCopilotStore.getState().toolCallsById
const toolCall = toolCallsById[this.toolCallId]
const params = toolCall?.params as KnowledgeBaseArgs | undefined
// Only require confirmation for create operation
if (params?.operation === 'create') {
const name = params?.args?.name || 'new knowledge base'
return {
accept: { text: `Create "${name}"`, icon: PlusCircle },
reject: { text: 'Skip', icon: XCircle },
}
}
// No interrupt for list, get, query - auto-execute
return undefined
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Accessing knowledge base', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Accessing knowledge base', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Accessing knowledge base', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Accessed knowledge base', icon: Database },
[ClientToolCallState.error]: { text: 'Failed to access knowledge base', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted knowledge base access', icon: MinusCircle },
[ClientToolCallState.rejected]: { text: 'Skipped knowledge base access', icon: MinusCircle },
},
getDynamicText: (params: Record<string, any>, state: ClientToolCallState) => {
const operation = params?.operation as string | undefined
const name = params?.args?.name as string | undefined
const opVerbs: Record<string, { active: string; past: string; pending?: string }> = {
create: {
active: 'Creating knowledge base',
past: 'Created knowledge base',
pending: name ? `Create knowledge base "${name}"?` : 'Create knowledge base?',
},
list: { active: 'Listing knowledge bases', past: 'Listed knowledge bases' },
get: { active: 'Getting knowledge base', past: 'Retrieved knowledge base' },
query: { active: 'Querying knowledge base', past: 'Queried knowledge base' },
}
const defaultVerb: { active: string; past: string; pending?: string } = {
active: 'Accessing knowledge base',
past: 'Accessed knowledge base',
}
const verb = operation ? opVerbs[operation] || defaultVerb : defaultVerb
if (state === ClientToolCallState.success) {
return verb.past
}
if (state === ClientToolCallState.pending && verb.pending) {
return verb.pending
}
if (
state === ClientToolCallState.generating ||
state === ClientToolCallState.pending ||
state === ClientToolCallState.executing
) {
return verb.active
}
return undefined
},
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(): Promise<void> {
await this.execute()
}
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,24 +0,0 @@
const instances: Record<string, any> = {}
let syncStateFn: ((toolCallId: string, nextState: any, options?: { result?: any }) => void) | null =
null
export function registerClientTool(toolCallId: string, instance: any) {
instances[toolCallId] = instance
}
export function getClientTool(toolCallId: string): any | undefined {
return instances[toolCallId]
}
export function registerToolStateSync(
fn: (toolCallId: string, nextState: any, options?: { result?: any }) => void
) {
syncStateFn = fn
}
export function syncToolState(toolCallId: string, nextState: any, options?: { result?: any }) {
try {
syncStateFn?.(toolCallId, nextState, options)
} catch {}
}

View File

@@ -1,241 +0,0 @@
import { createLogger } from '@sim/logger'
import { Loader2, Navigation, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { useCopilotStore } from '@/stores/panel/copilot/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
type NavigationDestination = 'workflow' | 'logs' | 'templates' | 'vector_db' | 'settings'
interface NavigateUIArgs {
destination: NavigationDestination
workflowName?: string
}
export class NavigateUIClientTool extends BaseClientTool {
static readonly id = 'navigate_ui'
constructor(toolCallId: string) {
super(toolCallId, NavigateUIClientTool.id, NavigateUIClientTool.metadata)
}
/**
* Override to provide dynamic button text based on destination
*/
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
const toolCallsById = useCopilotStore.getState().toolCallsById
const toolCall = toolCallsById[this.toolCallId]
const params = toolCall?.params as NavigateUIArgs | undefined
const destination = params?.destination
const workflowName = params?.workflowName
let buttonText = 'Navigate'
if (destination === 'workflow' && workflowName) {
buttonText = 'Open workflow'
} else if (destination === 'logs') {
buttonText = 'Open logs'
} else if (destination === 'templates') {
buttonText = 'Open templates'
} else if (destination === 'vector_db') {
buttonText = 'Open vector DB'
} else if (destination === 'settings') {
buttonText = 'Open settings'
}
return {
accept: { text: buttonText, icon: Navigation },
reject: { text: 'Skip', icon: XCircle },
}
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
text: 'Preparing to open',
icon: Loader2,
},
[ClientToolCallState.pending]: { text: 'Open?', icon: Navigation },
[ClientToolCallState.executing]: { text: 'Opening', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Opened', icon: Navigation },
[ClientToolCallState.error]: { text: 'Failed to open', icon: X },
[ClientToolCallState.aborted]: {
text: 'Aborted opening',
icon: XCircle,
},
[ClientToolCallState.rejected]: {
text: 'Skipped opening',
icon: XCircle,
},
},
interrupt: {
accept: { text: 'Open', icon: Navigation },
reject: { text: 'Skip', icon: XCircle },
},
getDynamicText: (params, state) => {
const destination = params?.destination as NavigationDestination | undefined
const workflowName = params?.workflowName
const action = 'open'
const actionCapitalized = 'Open'
const actionPast = 'opened'
const actionIng = 'opening'
let target = ''
if (destination === 'workflow' && workflowName) {
target = ` workflow "${workflowName}"`
} else if (destination === 'workflow') {
target = ' workflows'
} else if (destination === 'logs') {
target = ' logs'
} else if (destination === 'templates') {
target = ' templates'
} else if (destination === 'vector_db') {
target = ' vector database'
} else if (destination === 'settings') {
target = ' settings'
}
const fullAction = `${action}${target}`
const fullActionCapitalized = `${actionCapitalized}${target}`
const fullActionPast = `${actionPast}${target}`
const fullActionIng = `${actionIng}${target}`
switch (state) {
case ClientToolCallState.success:
return fullActionPast.charAt(0).toUpperCase() + fullActionPast.slice(1)
case ClientToolCallState.executing:
return fullActionIng.charAt(0).toUpperCase() + fullActionIng.slice(1)
case ClientToolCallState.generating:
return `Preparing to ${fullAction}`
case ClientToolCallState.pending:
return `${fullActionCapitalized}?`
case ClientToolCallState.error:
return `Failed to ${fullAction}`
case ClientToolCallState.aborted:
return `Aborted ${fullAction}`
case ClientToolCallState.rejected:
return `Skipped ${fullAction}`
}
return undefined
},
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: NavigateUIArgs): Promise<void> {
const logger = createLogger('NavigateUIClientTool')
try {
this.setState(ClientToolCallState.executing)
// Get params from copilot store if not provided directly
let destination = args?.destination
let workflowName = args?.workflowName
if (!destination) {
const toolCallsById = useCopilotStore.getState().toolCallsById
const toolCall = toolCallsById[this.toolCallId]
const params = toolCall?.params as NavigateUIArgs | undefined
destination = params?.destination
workflowName = params?.workflowName
}
if (!destination) {
throw new Error('No destination provided')
}
let navigationUrl = ''
let successMessage = ''
// Get current workspace ID from URL
const workspaceId = window.location.pathname.split('/')[2]
switch (destination) {
case 'workflow':
if (workflowName) {
// Find workflow by name
const { workflows } = useWorkflowRegistry.getState()
const workflow = Object.values(workflows).find(
(w) => w.name.toLowerCase() === workflowName.toLowerCase()
)
if (!workflow) {
throw new Error(`Workflow "${workflowName}" not found`)
}
navigationUrl = `/workspace/${workspaceId}/w/${workflow.id}`
successMessage = `Navigated to workflow "${workflowName}"`
} else {
navigationUrl = `/workspace/${workspaceId}/w`
successMessage = 'Navigated to workflows'
}
break
case 'logs':
navigationUrl = `/workspace/${workspaceId}/logs`
successMessage = 'Navigated to logs'
break
case 'templates':
navigationUrl = `/workspace/${workspaceId}/templates`
successMessage = 'Navigated to templates'
break
case 'vector_db':
navigationUrl = `/workspace/${workspaceId}/vector-db`
successMessage = 'Navigated to vector database'
break
case 'settings':
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'general' } }))
successMessage = 'Opened settings'
break
default:
throw new Error(`Unknown destination: ${destination}`)
}
// Navigate if URL was set
if (navigationUrl) {
window.location.href = navigationUrl
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, successMessage, {
destination,
workflowName,
navigated: true,
})
} catch (e: any) {
logger.error('Navigation failed', { message: e?.message })
this.setState(ClientToolCallState.error)
// Get destination info for better error message
const toolCallsById = useCopilotStore.getState().toolCallsById
const toolCall = toolCallsById[this.toolCallId]
const params = toolCall?.params as NavigateUIArgs | undefined
const dest = params?.destination
const wfName = params?.workflowName
let errorMessage = e?.message || 'Failed to navigate'
if (dest === 'workflow' && wfName) {
errorMessage = `Failed to navigate to workflow "${wfName}": ${e?.message || 'Unknown error'}`
} else if (dest) {
errorMessage = `Failed to navigate to ${dest}: ${e?.message || 'Unknown error'}`
}
await this.markToolComplete(500, errorMessage)
}
}
async execute(args?: NavigateUIArgs): Promise<void> {
await this.handleAccept(args)
}
}

View File

@@ -1,56 +0,0 @@
import { KeyRound, Loader2, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface AuthArgs {
instruction: string
}
/**
* Auth tool that spawns a subagent to handle authentication setup.
* This tool auto-executes and the actual work is done by the auth subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class AuthClientTool extends BaseClientTool {
static readonly id = 'auth'
constructor(toolCallId: string) {
super(toolCallId, AuthClientTool.id, AuthClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Authenticating', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Authenticating', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Authenticating', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Authenticated', icon: KeyRound },
[ClientToolCallState.error]: { text: 'Failed to authenticate', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped auth', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted auth', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Authenticating',
completedLabel: 'Authenticated',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the auth tool.
* This just marks the tool as executing - the actual auth work is done server-side
* by the auth subagent, and its output is streamed as subagent events.
*/
async execute(_args?: AuthArgs): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
// Register UI config at module load
registerToolUIConfig(AuthClientTool.id, AuthClientTool.metadata.uiConfig!)

View File

@@ -1,61 +0,0 @@
import { createLogger } from '@sim/logger'
import { Check, Loader2, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
interface CheckoffTodoArgs {
id?: string
todoId?: string
}
export class CheckoffTodoClientTool extends BaseClientTool {
static readonly id = 'checkoff_todo'
constructor(toolCallId: string) {
super(toolCallId, CheckoffTodoClientTool.id, CheckoffTodoClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Marking todo', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Marking todo', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Marked todo complete', icon: Check },
[ClientToolCallState.error]: { text: 'Failed to mark todo', icon: XCircle },
},
}
async execute(args?: CheckoffTodoArgs): Promise<void> {
const logger = createLogger('CheckoffTodoClientTool')
try {
this.setState(ClientToolCallState.executing)
const todoId = args?.id || args?.todoId
if (!todoId) {
this.setState(ClientToolCallState.error)
await this.markToolComplete(400, 'Missing todo id')
return
}
try {
const { useCopilotStore } = await import('@/stores/panel/copilot/store')
const store = useCopilotStore.getState()
if (store.updatePlanTodoStatus) {
store.updatePlanTodoStatus(todoId, 'completed')
}
} catch (e) {
logger.warn('Failed to update todo status in store', { message: (e as any)?.message })
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'Todo checked off', { todoId })
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 check off todo')
}
}
}

View File

@@ -1,52 +0,0 @@
import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class CrawlWebsiteClientTool extends BaseClientTool {
static readonly id = 'crawl_website'
constructor(toolCallId: string) {
super(toolCallId, CrawlWebsiteClientTool.id, CrawlWebsiteClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Crawling website', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Crawling website', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Crawling website', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Crawled website', icon: Globe },
[ClientToolCallState.error]: { text: 'Failed to crawl website', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted crawling website', icon: MinusCircle },
[ClientToolCallState.rejected]: { text: 'Skipped crawling website', icon: MinusCircle },
},
interrupt: undefined,
getDynamicText: (params, state) => {
if (params?.url && typeof params.url === 'string') {
const url = params.url
switch (state) {
case ClientToolCallState.success:
return `Crawled ${url}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Crawling ${url}`
case ClientToolCallState.error:
return `Failed to crawl ${url}`
case ClientToolCallState.aborted:
return `Aborted crawling ${url}`
case ClientToolCallState.rejected:
return `Skipped crawling ${url}`
}
}
return undefined
},
}
async execute(): Promise<void> {
return
}
}

View File

@@ -1,56 +0,0 @@
import { Loader2, Wrench, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface CustomToolArgs {
instruction: string
}
/**
* Custom tool that spawns a subagent to manage custom tools.
* This tool auto-executes and the actual work is done by the custom_tool subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class CustomToolClientTool extends BaseClientTool {
static readonly id = 'custom_tool'
constructor(toolCallId: string) {
super(toolCallId, CustomToolClientTool.id, CustomToolClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Managing custom tool', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Managing custom tool', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Managing custom tool', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Managed custom tool', icon: Wrench },
[ClientToolCallState.error]: { text: 'Failed custom tool', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped custom tool', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted custom tool', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Managing custom tool',
completedLabel: 'Custom tool managed',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the custom_tool tool.
* This just marks the tool as executing - the actual custom tool work is done server-side
* by the custom_tool subagent, and its output is streamed as subagent events.
*/
async execute(_args?: CustomToolArgs): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
// Register UI config at module load
registerToolUIConfig(CustomToolClientTool.id, CustomToolClientTool.metadata.uiConfig!)

View File

@@ -1,60 +0,0 @@
import { Bug, Loader2, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface DebugArgs {
error_description: string
context?: string
}
/**
* Debug tool that spawns a subagent to diagnose workflow issues.
* This tool auto-executes and the actual work is done by the debug subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class DebugClientTool extends BaseClientTool {
static readonly id = 'debug'
constructor(toolCallId: string) {
super(toolCallId, DebugClientTool.id, DebugClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Debugging', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Debugging', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Debugging', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Debugged', icon: Bug },
[ClientToolCallState.error]: { text: 'Failed to debug', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped debug', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted debug', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Debugging',
completedLabel: 'Debugged',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the debug tool.
* This just marks the tool as executing - the actual debug work is done server-side
* by the debug subagent, and its output is streamed as subagent events.
*/
async execute(_args?: DebugArgs): Promise<void> {
// Immediately transition to executing state - no user confirmation needed
this.setState(ClientToolCallState.executing)
// The tool result will come from the server via tool_result event
// when the debug subagent completes its work
}
}
// Register UI config at module load
registerToolUIConfig(DebugClientTool.id, DebugClientTool.metadata.uiConfig!)

View File

@@ -1,56 +0,0 @@
import { Loader2, Rocket, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface DeployArgs {
instruction: string
}
/**
* Deploy tool that spawns a subagent to handle deployment.
* This tool auto-executes and the actual work is done by the deploy subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class DeployClientTool extends BaseClientTool {
static readonly id = 'deploy'
constructor(toolCallId: string) {
super(toolCallId, DeployClientTool.id, DeployClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Deploying', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Deploying', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Deploying', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Deployed', icon: Rocket },
[ClientToolCallState.error]: { text: 'Failed to deploy', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped deploy', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted deploy', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Deploying',
completedLabel: 'Deployed',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the deploy tool.
* This just marks the tool as executing - the actual deploy work is done server-side
* by the deploy subagent, and its output is streamed as subagent events.
*/
async execute(_args?: DeployArgs): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
// Register UI config at module load
registerToolUIConfig(DeployClientTool.id, DeployClientTool.metadata.uiConfig!)

View File

@@ -1,61 +0,0 @@
import { Loader2, Pencil, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface EditArgs {
instruction: string
}
/**
* Edit tool that spawns a subagent to apply code/workflow edits.
* This tool auto-executes and the actual work is done by the edit subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class EditClientTool extends BaseClientTool {
static readonly id = 'edit'
constructor(toolCallId: string) {
super(toolCallId, EditClientTool.id, EditClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Editing', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Editing', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Editing', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Edited', icon: Pencil },
[ClientToolCallState.error]: { text: 'Failed to apply edit', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped edit', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted edit', icon: XCircle },
},
uiConfig: {
isSpecial: true,
subagent: {
streamingLabel: 'Editing',
completedLabel: 'Edited',
shouldCollapse: false, // Edit subagent stays expanded
outputArtifacts: ['edit_summary'],
hideThinkingText: true, // We show WorkflowEditSummary instead
},
},
}
/**
* Execute the edit tool.
* This just marks the tool as executing - the actual edit work is done server-side
* by the edit subagent, and its output is streamed as subagent events.
*/
async execute(_args?: EditArgs): Promise<void> {
// Immediately transition to executing state - no user confirmation needed
this.setState(ClientToolCallState.executing)
// The tool result will come from the server via tool_result event
// when the edit subagent completes its work
}
}
// Register UI config at module load
registerToolUIConfig(EditClientTool.id, EditClientTool.metadata.uiConfig!)

View File

@@ -1,56 +0,0 @@
import { ClipboardCheck, Loader2, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface EvaluateArgs {
instruction: string
}
/**
* Evaluate tool that spawns a subagent to evaluate workflows or outputs.
* This tool auto-executes and the actual work is done by the evaluate subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class EvaluateClientTool extends BaseClientTool {
static readonly id = 'evaluate'
constructor(toolCallId: string) {
super(toolCallId, EvaluateClientTool.id, EvaluateClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Evaluating', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Evaluating', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Evaluating', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Evaluated', icon: ClipboardCheck },
[ClientToolCallState.error]: { text: 'Failed to evaluate', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped evaluation', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted evaluation', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Evaluating',
completedLabel: 'Evaluated',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the evaluate tool.
* This just marks the tool as executing - the actual evaluation work is done server-side
* by the evaluate subagent, and its output is streamed as subagent events.
*/
async execute(_args?: EvaluateArgs): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
// Register UI config at module load
registerToolUIConfig(EvaluateClientTool.id, EvaluateClientTool.metadata.uiConfig!)

View File

@@ -1,53 +0,0 @@
import { FileText, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class GetPageContentsClientTool extends BaseClientTool {
static readonly id = 'get_page_contents'
constructor(toolCallId: string) {
super(toolCallId, GetPageContentsClientTool.id, GetPageContentsClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Getting page contents', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Getting page contents', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Getting page contents', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Retrieved page contents', icon: FileText },
[ClientToolCallState.error]: { text: 'Failed to get page contents', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted getting page contents', icon: MinusCircle },
[ClientToolCallState.rejected]: { text: 'Skipped getting page contents', icon: MinusCircle },
},
interrupt: undefined,
getDynamicText: (params, state) => {
if (params?.urls && Array.isArray(params.urls) && params.urls.length > 0) {
const firstUrl = String(params.urls[0])
const count = params.urls.length
switch (state) {
case ClientToolCallState.success:
return count > 1 ? `Retrieved ${count} pages` : `Retrieved ${firstUrl}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return count > 1 ? `Getting ${count} pages` : `Getting ${firstUrl}`
case ClientToolCallState.error:
return count > 1 ? `Failed to get ${count} pages` : `Failed to get ${firstUrl}`
case ClientToolCallState.aborted:
return count > 1 ? `Aborted getting ${count} pages` : `Aborted getting ${firstUrl}`
case ClientToolCallState.rejected:
return count > 1 ? `Skipped getting ${count} pages` : `Skipped getting ${firstUrl}`
}
}
return undefined
},
}
async execute(): Promise<void> {
return
}
}

View File

@@ -1,56 +0,0 @@
import { Info, Loader2, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface InfoArgs {
instruction: string
}
/**
* Info tool that spawns a subagent to retrieve information.
* This tool auto-executes and the actual work is done by the info subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class InfoClientTool extends BaseClientTool {
static readonly id = 'info'
constructor(toolCallId: string) {
super(toolCallId, InfoClientTool.id, InfoClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Getting info', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Getting info', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Getting info', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Retrieved info', icon: Info },
[ClientToolCallState.error]: { text: 'Failed to get info', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped info', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted info', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Getting info',
completedLabel: 'Info retrieved',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the info tool.
* This just marks the tool as executing - the actual info work is done server-side
* by the info subagent, and its output is streamed as subagent events.
*/
async execute(_args?: InfoArgs): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
// Register UI config at module load
registerToolUIConfig(InfoClientTool.id, InfoClientTool.metadata.uiConfig!)

View File

@@ -1,56 +0,0 @@
import { BookOpen, Loader2, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface KnowledgeArgs {
instruction: string
}
/**
* Knowledge tool that spawns a subagent to manage knowledge bases.
* This tool auto-executes and the actual work is done by the knowledge subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class KnowledgeClientTool extends BaseClientTool {
static readonly id = 'knowledge'
constructor(toolCallId: string) {
super(toolCallId, KnowledgeClientTool.id, KnowledgeClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Managing knowledge', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Managing knowledge', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Managing knowledge', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Managed knowledge', icon: BookOpen },
[ClientToolCallState.error]: { text: 'Failed to manage knowledge', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped knowledge', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted knowledge', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Managing knowledge',
completedLabel: 'Knowledge managed',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the knowledge tool.
* This just marks the tool as executing - the actual knowledge search work is done server-side
* by the knowledge subagent, and its output is streamed as subagent events.
*/
async execute(_args?: KnowledgeArgs): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
// Register UI config at module load
registerToolUIConfig(KnowledgeClientTool.id, KnowledgeClientTool.metadata.uiConfig!)

View File

@@ -1,109 +0,0 @@
import { createLogger } from '@sim/logger'
import { Globe2, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
interface MakeApiRequestArgs {
url: string
method: 'GET' | 'POST' | 'PUT'
queryParams?: Record<string, string | number | boolean>
headers?: Record<string, string>
body?: any
}
export class MakeApiRequestClientTool extends BaseClientTool {
static readonly id = 'make_api_request'
constructor(toolCallId: string) {
super(toolCallId, MakeApiRequestClientTool.id, MakeApiRequestClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Preparing API request', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Review API request', icon: Globe2 },
[ClientToolCallState.executing]: { text: 'Executing API request', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Completed API request', icon: Globe2 },
[ClientToolCallState.error]: { text: 'Failed to execute API request', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped API request', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Aborted API request', icon: XCircle },
},
interrupt: {
accept: { text: 'Execute', icon: Globe2 },
reject: { text: 'Skip', icon: MinusCircle },
},
uiConfig: {
interrupt: {
accept: { text: 'Execute', icon: Globe2 },
reject: { text: 'Skip', icon: MinusCircle },
showAllowOnce: true,
showAllowAlways: true,
},
paramsTable: {
columns: [
{ key: 'method', label: 'Method', width: '26%', editable: true, mono: true },
{ key: 'url', label: 'Endpoint', width: '74%', editable: true, mono: true },
],
extractRows: (params) => {
return [['request', (params.method || 'GET').toUpperCase(), params.url || '']]
},
},
},
getDynamicText: (params, state) => {
if (params?.url && typeof params.url === 'string') {
const method = params.method || 'GET'
let url = params.url
// Extract domain from URL for cleaner display
try {
const urlObj = new URL(url)
url = urlObj.hostname + urlObj.pathname
} catch {
// Use URL as-is if parsing fails
}
switch (state) {
case ClientToolCallState.success:
return `${method} ${url} complete`
case ClientToolCallState.executing:
return `${method} ${url}`
case ClientToolCallState.generating:
return `Preparing ${method} ${url}`
case ClientToolCallState.pending:
return `Review ${method} ${url}`
case ClientToolCallState.error:
return `Failed ${method} ${url}`
case ClientToolCallState.rejected:
return `Skipped ${method} ${url}`
case ClientToolCallState.aborted:
return `Aborted ${method} ${url}`
}
}
return undefined
},
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(_args?: MakeApiRequestArgs): Promise<void> {
// Tool execution is handled server-side by the orchestrator.
this.setState(ClientToolCallState.executing)
}
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)
}
}
// Register UI config at module load
registerToolUIConfig(MakeApiRequestClientTool.id, MakeApiRequestClientTool.metadata.uiConfig!)

View File

@@ -1,64 +0,0 @@
import { createLogger } from '@sim/logger'
import { Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
interface MarkTodoInProgressArgs {
id?: string
todoId?: string
}
export class MarkTodoInProgressClientTool extends BaseClientTool {
static readonly id = 'mark_todo_in_progress'
constructor(toolCallId: string) {
super(toolCallId, MarkTodoInProgressClientTool.id, MarkTodoInProgressClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Marking todo in progress', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Marking todo in progress', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Marking todo in progress', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Marked todo in progress', icon: Loader2 },
[ClientToolCallState.error]: { text: 'Failed to mark in progress', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted marking in progress', icon: MinusCircle },
[ClientToolCallState.rejected]: { text: 'Skipped marking in progress', icon: MinusCircle },
},
}
async execute(args?: MarkTodoInProgressArgs): Promise<void> {
const logger = createLogger('MarkTodoInProgressClientTool')
try {
this.setState(ClientToolCallState.executing)
const todoId = args?.id || args?.todoId
if (!todoId) {
this.setState(ClientToolCallState.error)
await this.markToolComplete(400, 'Missing todo id')
return
}
try {
const { useCopilotStore } = await import('@/stores/panel/copilot/store')
const store = useCopilotStore.getState()
if (store.updatePlanTodoStatus) {
store.updatePlanTodoStatus(todoId, 'executing')
}
} catch (e) {
logger.warn('Failed to update todo status in store', { message: (e as any)?.message })
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'Todo marked in progress', { todoId })
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 mark todo in progress')
}
}
}

View File

@@ -1,174 +0,0 @@
import { createLogger } from '@sim/logger'
import { CheckCircle, Loader2, MinusCircle, PlugZap, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth'
const logger = createLogger('OAuthRequestAccessClientTool')
interface OAuthRequestAccessArgs {
providerName?: string
}
interface ResolvedServiceInfo {
serviceId: string
providerId: string
service: OAuthServiceConfig
}
/**
* Finds the service configuration from a provider name.
* The providerName should match the exact `name` field returned by get_credentials tool's notConnected services.
*/
function findServiceByName(providerName: string): ResolvedServiceInfo | null {
const normalizedName = providerName.toLowerCase().trim()
// First pass: exact match (case-insensitive)
for (const [, providerConfig] of Object.entries(OAUTH_PROVIDERS)) {
for (const [serviceId, service] of Object.entries(providerConfig.services)) {
if (service.name.toLowerCase() === normalizedName) {
return { serviceId, providerId: service.providerId, service }
}
}
}
// Second pass: partial match as fallback for flexibility
for (const [, providerConfig] of Object.entries(OAUTH_PROVIDERS)) {
for (const [serviceId, service] of Object.entries(providerConfig.services)) {
if (
service.name.toLowerCase().includes(normalizedName) ||
normalizedName.includes(service.name.toLowerCase())
) {
return { serviceId, providerId: service.providerId, service }
}
}
}
return null
}
export interface OAuthConnectEventDetail {
providerName: string
serviceId: string
providerId: string
requiredScopes: string[]
newScopes?: string[]
}
export class OAuthRequestAccessClientTool extends BaseClientTool {
static readonly id = 'oauth_request_access'
private providerName?: string
constructor(toolCallId: string) {
super(toolCallId, OAuthRequestAccessClientTool.id, OAuthRequestAccessClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Requesting integration access', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Requesting integration access', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Requesting integration access', icon: Loader2 },
[ClientToolCallState.rejected]: { text: 'Skipped integration access', icon: MinusCircle },
[ClientToolCallState.success]: { text: 'Requested integration access', icon: CheckCircle },
[ClientToolCallState.error]: { text: 'Failed to request integration access', icon: X },
[ClientToolCallState.aborted]: { text: 'Aborted integration access request', icon: XCircle },
},
interrupt: {
accept: { text: 'Connect', icon: PlugZap },
reject: { text: 'Skip', icon: MinusCircle },
},
getDynamicText: (params, state) => {
if (params.providerName) {
const name = params.providerName
switch (state) {
case ClientToolCallState.generating:
case ClientToolCallState.pending:
case ClientToolCallState.executing:
return `Requesting ${name} access`
case ClientToolCallState.rejected:
return `Skipped ${name} access`
case ClientToolCallState.success:
return `Requested ${name} access`
case ClientToolCallState.error:
return `Failed to request ${name} access`
case ClientToolCallState.aborted:
return `Aborted ${name} access request`
}
}
return undefined
},
}
async handleAccept(args?: OAuthRequestAccessArgs): Promise<void> {
try {
if (args?.providerName) {
this.providerName = args.providerName
}
if (!this.providerName) {
logger.error('No provider name provided')
this.setState(ClientToolCallState.error)
await this.markToolComplete(400, 'No provider name specified')
return
}
// Find the service by name
const serviceInfo = findServiceByName(this.providerName)
if (!serviceInfo) {
logger.error('Could not find OAuth service for provider', {
providerName: this.providerName,
})
this.setState(ClientToolCallState.error)
await this.markToolComplete(400, `Unknown provider: ${this.providerName}`)
return
}
const { serviceId, providerId, service } = serviceInfo
logger.info('Opening OAuth connect modal', {
providerName: this.providerName,
serviceId,
providerId,
})
// Move to executing state
this.setState(ClientToolCallState.executing)
// Dispatch event to open the OAuth modal (same pattern as open-settings)
window.dispatchEvent(
new CustomEvent<OAuthConnectEventDetail>('open-oauth-connect', {
detail: {
providerName: this.providerName,
serviceId,
providerId,
requiredScopes: service.scopes || [],
},
})
)
// Mark as success - the user opened the prompt, but connection is not guaranteed
this.setState(ClientToolCallState.success)
await this.markToolComplete(
200,
`The user opened the ${this.providerName} connection prompt and may have connected. Check the connected integrations to verify the connection status.`
)
} catch (e) {
logger.error('Failed to open OAuth connect modal', { error: e })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, 'Failed to open OAuth connection dialog')
}
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async execute(args?: OAuthRequestAccessArgs): Promise<void> {
await this.handleAccept(args)
}
}

View File

@@ -1,59 +0,0 @@
import { ListTodo, Loader2, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface PlanArgs {
request: string
}
/**
* Plan tool that spawns a subagent to plan an approach.
* This tool auto-executes and the actual work is done by the plan subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class PlanClientTool extends BaseClientTool {
static readonly id = 'plan'
constructor(toolCallId: string) {
super(toolCallId, PlanClientTool.id, PlanClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Planning', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Planning', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Planning', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Planned', icon: ListTodo },
[ClientToolCallState.error]: { text: 'Failed to plan', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped plan', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted plan', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Planning',
completedLabel: 'Planned',
shouldCollapse: true,
outputArtifacts: ['plan'],
},
},
}
/**
* Execute the plan tool.
* This just marks the tool as executing - the actual planning work is done server-side
* by the plan subagent, and its output is streamed as subagent events.
*/
async execute(_args?: PlanArgs): Promise<void> {
// Immediately transition to executing state - no user confirmation needed
this.setState(ClientToolCallState.executing)
// The tool result will come from the server via tool_result event
// when the plan subagent completes its work
}
}
// Register UI config at module load
registerToolUIConfig(PlanClientTool.id, PlanClientTool.metadata.uiConfig!)

View File

@@ -1,76 +0,0 @@
import { CheckCircle2, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class RememberDebugClientTool extends BaseClientTool {
static readonly id = 'remember_debug'
constructor(toolCallId: string) {
super(toolCallId, RememberDebugClientTool.id, RememberDebugClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Validating fix', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Validating fix', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Validating fix', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Validated fix', icon: CheckCircle2 },
[ClientToolCallState.error]: { text: 'Failed to validate', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted validation', icon: MinusCircle },
[ClientToolCallState.rejected]: { text: 'Skipped validation', icon: MinusCircle },
},
interrupt: undefined,
getDynamicText: (params, state) => {
const operation = params?.operation
if (operation === 'add' || operation === 'edit') {
// For add/edit, show from problem or solution
const text = params?.problem || params?.solution
if (text && typeof text === 'string') {
switch (state) {
case ClientToolCallState.success:
return `Validated fix ${text}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Validating fix ${text}`
case ClientToolCallState.error:
return `Failed to validate fix ${text}`
case ClientToolCallState.aborted:
return `Aborted validating fix ${text}`
case ClientToolCallState.rejected:
return `Skipped validating fix ${text}`
}
}
} else if (operation === 'delete') {
// For delete, show from problem or solution (or id as fallback)
const text = params?.problem || params?.solution || params?.id
if (text && typeof text === 'string') {
switch (state) {
case ClientToolCallState.success:
return `Adjusted fix ${text}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Adjusting fix ${text}`
case ClientToolCallState.error:
return `Failed to adjust fix ${text}`
case ClientToolCallState.aborted:
return `Aborted adjusting fix ${text}`
case ClientToolCallState.rejected:
return `Skipped adjusting fix ${text}`
}
}
}
return undefined
},
}
async execute(): Promise<void> {
return
}
}

View File

@@ -1,56 +0,0 @@
import { Loader2, Search, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface ResearchArgs {
instruction: string
}
/**
* Research tool that spawns a subagent to research information.
* This tool auto-executes and the actual work is done by the research subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class ResearchClientTool extends BaseClientTool {
static readonly id = 'research'
constructor(toolCallId: string) {
super(toolCallId, ResearchClientTool.id, ResearchClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Researching', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Researching', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Researching', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Researched', icon: Search },
[ClientToolCallState.error]: { text: 'Failed to research', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped research', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted research', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Researching',
completedLabel: 'Researched',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the research tool.
* This just marks the tool as executing - the actual research work is done server-side
* by the research subagent, and its output is streamed as subagent events.
*/
async execute(_args?: ResearchArgs): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
// Register UI config at module load
registerToolUIConfig(ResearchClientTool.id, ResearchClientTool.metadata.uiConfig!)

View File

@@ -1,52 +0,0 @@
import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class ScrapePageClientTool extends BaseClientTool {
static readonly id = 'scrape_page'
constructor(toolCallId: string) {
super(toolCallId, ScrapePageClientTool.id, ScrapePageClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Scraping page', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Scraping page', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Scraping page', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Scraped page', icon: Globe },
[ClientToolCallState.error]: { text: 'Failed to scrape page', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted scraping page', icon: MinusCircle },
[ClientToolCallState.rejected]: { text: 'Skipped scraping page', icon: MinusCircle },
},
interrupt: undefined,
getDynamicText: (params, state) => {
if (params?.url && typeof params.url === 'string') {
const url = params.url
switch (state) {
case ClientToolCallState.success:
return `Scraped ${url}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Scraping ${url}`
case ClientToolCallState.error:
return `Failed to scrape ${url}`
case ClientToolCallState.aborted:
return `Aborted scraping ${url}`
case ClientToolCallState.rejected:
return `Skipped scraping ${url}`
}
}
return undefined
},
}
async execute(): Promise<void> {
return
}
}

View File

@@ -1,53 +0,0 @@
import { BookOpen, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class SearchDocumentationClientTool extends BaseClientTool {
static readonly id = 'search_documentation'
constructor(toolCallId: string) {
super(toolCallId, SearchDocumentationClientTool.id, SearchDocumentationClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Searching documentation', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Searching documentation', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Searching documentation', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Completed documentation search', icon: BookOpen },
[ClientToolCallState.error]: { text: 'Failed to search docs', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted documentation search', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped documentation search', icon: MinusCircle },
},
getDynamicText: (params, state) => {
if (params?.query && typeof params.query === 'string') {
const query = params.query
switch (state) {
case ClientToolCallState.success:
return `Searched docs for ${query}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Searching docs for ${query}`
case ClientToolCallState.error:
return `Failed to search docs for ${query}`
case ClientToolCallState.aborted:
return `Aborted searching docs for ${query}`
case ClientToolCallState.rejected:
return `Skipped searching docs for ${query}`
}
}
return undefined
},
}
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,52 +0,0 @@
import { Bug, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class SearchErrorsClientTool extends BaseClientTool {
static readonly id = 'search_errors'
constructor(toolCallId: string) {
super(toolCallId, SearchErrorsClientTool.id, SearchErrorsClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Debugging', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Debugging', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Debugging', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Debugged', icon: Bug },
[ClientToolCallState.error]: { text: 'Failed to debug', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted debugging', icon: MinusCircle },
[ClientToolCallState.rejected]: { text: 'Skipped debugging', icon: MinusCircle },
},
interrupt: undefined,
getDynamicText: (params, state) => {
if (params?.query && typeof params.query === 'string') {
const query = params.query
switch (state) {
case ClientToolCallState.success:
return `Debugged ${query}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Debugging ${query}`
case ClientToolCallState.error:
return `Failed to debug ${query}`
case ClientToolCallState.aborted:
return `Aborted debugging ${query}`
case ClientToolCallState.rejected:
return `Skipped debugging ${query}`
}
}
return undefined
},
}
async execute(): Promise<void> {
return
}
}

View File

@@ -1,50 +0,0 @@
import { BookOpen, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class SearchLibraryDocsClientTool extends BaseClientTool {
static readonly id = 'search_library_docs'
constructor(toolCallId: string) {
super(toolCallId, SearchLibraryDocsClientTool.id, SearchLibraryDocsClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Reading docs', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Reading docs', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Reading docs', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Read docs', icon: BookOpen },
[ClientToolCallState.error]: { text: 'Failed to read docs', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted reading docs', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped reading docs', icon: MinusCircle },
},
getDynamicText: (params, state) => {
const libraryName = params?.library_name
if (libraryName && typeof libraryName === 'string') {
switch (state) {
case ClientToolCallState.success:
return `Read ${libraryName} docs`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Reading ${libraryName} docs`
case ClientToolCallState.error:
return `Failed to read ${libraryName} docs`
case ClientToolCallState.aborted:
return `Aborted reading ${libraryName} docs`
case ClientToolCallState.rejected:
return `Skipped reading ${libraryName} docs`
}
}
return undefined
},
}
async execute(): Promise<void> {
return
}
}

View File

@@ -1,52 +0,0 @@
import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class SearchOnlineClientTool extends BaseClientTool {
static readonly id = 'search_online'
constructor(toolCallId: string) {
super(toolCallId, SearchOnlineClientTool.id, SearchOnlineClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Searching online', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Searching online', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Searching online', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Completed online search', icon: Globe },
[ClientToolCallState.error]: { text: 'Failed to search online', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped online search', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Aborted online search', icon: XCircle },
},
interrupt: undefined,
getDynamicText: (params, state) => {
if (params?.query && typeof params.query === 'string') {
const query = params.query
switch (state) {
case ClientToolCallState.success:
return `Searched online for ${query}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Searching online for ${query}`
case ClientToolCallState.error:
return `Failed to search online for ${query}`
case ClientToolCallState.aborted:
return `Aborted searching online for ${query}`
case ClientToolCallState.rejected:
return `Skipped searching online for ${query}`
}
}
return undefined
},
}
async execute(): Promise<void> {
return
}
}

View File

@@ -1,52 +0,0 @@
import { Loader2, MinusCircle, Search, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class SearchPatternsClientTool extends BaseClientTool {
static readonly id = 'search_patterns'
constructor(toolCallId: string) {
super(toolCallId, SearchPatternsClientTool.id, SearchPatternsClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Searching workflow patterns', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Searching workflow patterns', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Searching workflow patterns', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Found workflow patterns', icon: Search },
[ClientToolCallState.error]: { text: 'Failed to search patterns', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted pattern search', icon: MinusCircle },
[ClientToolCallState.rejected]: { text: 'Skipped pattern search', icon: MinusCircle },
},
interrupt: undefined,
getDynamicText: (params, state) => {
if (params?.queries && Array.isArray(params.queries) && params.queries.length > 0) {
const firstQuery = String(params.queries[0])
switch (state) {
case ClientToolCallState.success:
return `Searched ${firstQuery}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Searching ${firstQuery}`
case ClientToolCallState.error:
return `Failed to search ${firstQuery}`
case ClientToolCallState.aborted:
return `Aborted searching ${firstQuery}`
case ClientToolCallState.rejected:
return `Skipped searching ${firstQuery}`
}
}
return undefined
},
}
async execute(): Promise<void> {
return
}
}

View File

@@ -1,157 +0,0 @@
import { createLogger } from '@sim/logger'
import { Loader2, MinusCircle, Moon, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
/** Maximum sleep duration in seconds (3 minutes) */
const MAX_SLEEP_SECONDS = 180
/** Track sleep start times for calculating elapsed time on wake */
const sleepStartTimes: Record<string, number> = {}
interface SleepArgs {
seconds?: number
}
/**
* Format seconds into a human-readable duration string
*/
function formatDuration(seconds: number): string {
if (seconds >= 60) {
return `${Math.round(seconds / 60)} minute${seconds >= 120 ? 's' : ''}`
}
return `${seconds} second${seconds !== 1 ? 's' : ''}`
}
export class SleepClientTool extends BaseClientTool {
static readonly id = 'sleep'
constructor(toolCallId: string) {
super(toolCallId, SleepClientTool.id, SleepClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Preparing to sleep', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Sleeping', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Sleeping', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Finished sleeping', icon: Moon },
[ClientToolCallState.error]: { text: 'Interrupted sleep', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped sleep', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Aborted sleep', icon: MinusCircle },
[ClientToolCallState.background]: { text: 'Resumed', icon: Moon },
},
uiConfig: {
secondaryAction: {
text: 'Wake',
title: 'Wake',
variant: 'tertiary',
showInStates: [ClientToolCallState.executing],
targetState: ClientToolCallState.background,
},
},
// No interrupt - auto-execute immediately
getDynamicText: (params, state) => {
const seconds = params?.seconds
if (typeof seconds === 'number' && seconds > 0) {
const displayTime = formatDuration(seconds)
switch (state) {
case ClientToolCallState.success:
return `Slept for ${displayTime}`
case ClientToolCallState.executing:
case ClientToolCallState.pending:
return `Sleeping for ${displayTime}`
case ClientToolCallState.generating:
return `Preparing to sleep for ${displayTime}`
case ClientToolCallState.error:
return `Failed to sleep for ${displayTime}`
case ClientToolCallState.rejected:
return `Skipped sleeping for ${displayTime}`
case ClientToolCallState.aborted:
return `Aborted sleeping for ${displayTime}`
case ClientToolCallState.background: {
// Calculate elapsed time from when sleep started
const elapsedSeconds = params?._elapsedSeconds
if (typeof elapsedSeconds === 'number' && elapsedSeconds > 0) {
return `Resumed after ${formatDuration(Math.round(elapsedSeconds))}`
}
return 'Resumed early'
}
}
}
return undefined
},
}
/**
* Get elapsed seconds since sleep started
*/
getElapsedSeconds(): number {
const startTime = sleepStartTimes[this.toolCallId]
if (!startTime) return 0
return (Date.now() - startTime) / 1000
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: SleepArgs): Promise<void> {
const logger = createLogger('SleepClientTool')
// Use a timeout slightly longer than max sleep (3 minutes + buffer)
const timeoutMs = (MAX_SLEEP_SECONDS + 30) * 1000
await this.executeWithTimeout(async () => {
const params = args || {}
logger.debug('handleAccept() called', {
toolCallId: this.toolCallId,
state: this.getState(),
hasArgs: !!args,
seconds: params.seconds,
})
// Validate and clamp seconds
let seconds = typeof params.seconds === 'number' ? params.seconds : 0
if (seconds < 0) seconds = 0
if (seconds > MAX_SLEEP_SECONDS) seconds = MAX_SLEEP_SECONDS
logger.debug('Starting sleep', { seconds })
// Track start time for elapsed calculation
sleepStartTimes[this.toolCallId] = Date.now()
this.setState(ClientToolCallState.executing)
try {
// Sleep for the specified duration
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
logger.debug('Sleep completed successfully')
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, `Slept for ${seconds} seconds`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.error('Sleep failed', { error: message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, message)
} finally {
// Clean up start time tracking
delete sleepStartTimes[this.toolCallId]
}
}, timeoutMs)
}
async execute(args?: SleepArgs): Promise<void> {
// Auto-execute without confirmation - go straight to executing
await this.handleAccept(args)
}
}
// Register UI config at module load
registerToolUIConfig(SleepClientTool.id, SleepClientTool.metadata.uiConfig!)

View File

@@ -1,56 +0,0 @@
import { Loader2, Sparkles, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface SuperagentArgs {
instruction: string
}
/**
* Superagent tool that spawns a powerful subagent for complex tasks.
* This tool auto-executes and the actual work is done by the superagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class SuperagentClientTool extends BaseClientTool {
static readonly id = 'superagent'
constructor(toolCallId: string) {
super(toolCallId, SuperagentClientTool.id, SuperagentClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Superagent working', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Superagent working', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Superagent working', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Superagent completed', icon: Sparkles },
[ClientToolCallState.error]: { text: 'Superagent failed', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Superagent skipped', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Superagent aborted', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Superagent working',
completedLabel: 'Superagent completed',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the superagent tool.
* This just marks the tool as executing - the actual work is done server-side
* by the superagent, and its output is streamed as subagent events.
*/
async execute(_args?: SuperagentArgs): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
// Register UI config at module load
registerToolUIConfig(SuperagentClientTool.id, SuperagentClientTool.metadata.uiConfig!)

View File

@@ -1,56 +0,0 @@
import { FlaskConical, Loader2, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface TestArgs {
instruction: string
}
/**
* Test tool that spawns a subagent to run tests.
* This tool auto-executes and the actual work is done by the test subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class TestClientTool extends BaseClientTool {
static readonly id = 'test'
constructor(toolCallId: string) {
super(toolCallId, TestClientTool.id, TestClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Testing', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Testing', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Testing', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Tested', icon: FlaskConical },
[ClientToolCallState.error]: { text: 'Failed to test', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped test', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted test', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Testing',
completedLabel: 'Tested',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the test tool.
* This just marks the tool as executing - the actual test work is done server-side
* by the test subagent, and its output is streamed as subagent events.
*/
async execute(_args?: TestArgs): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
// Register UI config at module load
registerToolUIConfig(TestClientTool.id, TestClientTool.metadata.uiConfig!)

View File

@@ -1,56 +0,0 @@
import { Compass, Loader2, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface TourArgs {
instruction: string
}
/**
* Tour tool that spawns a subagent to guide the user.
* This tool auto-executes and the actual work is done by the tour subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class TourClientTool extends BaseClientTool {
static readonly id = 'tour'
constructor(toolCallId: string) {
super(toolCallId, TourClientTool.id, TourClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Touring', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Touring', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Touring', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Completed tour', icon: Compass },
[ClientToolCallState.error]: { text: 'Failed tour', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped tour', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted tour', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Touring',
completedLabel: 'Tour complete',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the tour tool.
* This just marks the tool as executing - the actual tour work is done server-side
* by the tour subagent, and its output is streamed as subagent events.
*/
async execute(_args?: TourArgs): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
// Register UI config at module load
registerToolUIConfig(TourClientTool.id, TourClientTool.metadata.uiConfig!)

View File

@@ -1,56 +0,0 @@
import { GitBranch, Loader2, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface WorkflowArgs {
instruction: string
}
/**
* Workflow tool that spawns a subagent to manage workflows.
* This tool auto-executes and the actual work is done by the workflow subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class WorkflowClientTool extends BaseClientTool {
static readonly id = 'workflow'
constructor(toolCallId: string) {
super(toolCallId, WorkflowClientTool.id, WorkflowClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Managing workflow', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Managing workflow', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Managing workflow', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Managed workflow', icon: GitBranch },
[ClientToolCallState.error]: { text: 'Failed to manage workflow', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped workflow', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted workflow', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Managing workflow',
completedLabel: 'Workflow managed',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the workflow tool.
* This just marks the tool as executing - the actual workflow work is done server-side
* by the workflow subagent, and its output is streamed as subagent events.
*/
async execute(_args?: WorkflowArgs): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
// Register UI config at module load
registerToolUIConfig(WorkflowClientTool.id, WorkflowClientTool.metadata.uiConfig!)

View File

@@ -1,34 +0,0 @@
import { createLogger } from '@sim/logger'
import type { ClientToolDefinition, ToolExecutionContext } from '@/lib/copilot/tools/client/types'
const logger = createLogger('ClientToolRegistry')
const tools: Record<string, ClientToolDefinition<any>> = {}
export function registerTool(def: ClientToolDefinition<any>) {
tools[def.name] = def
}
export function getTool(name: string): ClientToolDefinition<any> | undefined {
return tools[name]
}
export function createExecutionContext(params: {
toolCallId: string
toolName: string
}): ToolExecutionContext {
const { toolCallId, toolName } = params
return {
toolCallId,
toolName,
log: (level, message, extra) => {
try {
logger[level](message, { toolCallId, toolName, ...(extra || {}) })
} catch {}
},
}
}
export function getRegisteredTools(): Record<string, ClientToolDefinition<any>> {
return { ...tools }
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +0,0 @@
import type { BaseClientToolMetadata } from '@/lib/copilot/tools/client/base-tool'
import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool'
export interface ToolExecutionContext {
toolCallId: string
toolName: string
// Logging only; tools must not mutate store state directly
log: (
level: 'debug' | 'info' | 'warn' | 'error',
message: string,
extra?: Record<string, any>
) => void
}
export interface ToolRunResult {
status: number
message?: any
data?: any
}
export interface ClientToolDefinition<Args = any> {
name: string
metadata?: BaseClientToolMetadata
// Return true if this tool requires user confirmation before execution
hasInterrupt?: boolean | ((args?: Args) => boolean)
// Main execution entry point. Returns a result for the store to handle.
execute: (ctx: ToolExecutionContext, args?: Args) => Promise<ToolRunResult | undefined>
// Optional accept/reject handlers for interrupt flows
accept?: (ctx: ToolExecutionContext, args?: Args) => Promise<ToolRunResult | undefined>
reject?: (ctx: ToolExecutionContext, args?: Args) => Promise<ToolRunResult | undefined>
}
export { ClientToolCallState }

View File

@@ -1,238 +0,0 @@
/**
* UI Configuration Types for Copilot Tools
*
* This module defines the configuration interfaces that control how tools
* are rendered in the tool-call component. All UI behavior should be defined
* here rather than hardcoded in the rendering component.
*/
import type { LucideIcon } from 'lucide-react'
import type { ClientToolCallState } from './base-tool'
/**
* Configuration for a params table column
*/
export interface ParamsTableColumn {
/** Key to extract from params */
key: string
/** Display label for the column header */
label: string
/** Width as percentage or CSS value */
width?: string
/** Whether values in this column are editable */
editable?: boolean
/** Whether to use monospace font */
mono?: boolean
/** Whether to mask the value (for passwords) */
masked?: boolean
}
/**
* Configuration for params table rendering
*/
export interface ParamsTableConfig {
/** Column definitions */
columns: ParamsTableColumn[]
/**
* Extract rows from tool params.
* Returns array of [key, ...cellValues] for each row.
*/
extractRows: (params: Record<string, any>) => Array<[string, ...any[]]>
/**
* Optional: Update params when a cell is edited.
* Returns the updated params object.
*/
updateCell?: (
params: Record<string, any>,
rowKey: string,
columnKey: string,
newValue: any
) => Record<string, any>
}
/**
* Configuration for secondary action button (like "Move to Background")
*/
export interface SecondaryActionConfig {
/** Button text */
text: string
/** Button title/tooltip */
title?: string
/** Button variant */
variant?: 'tertiary' | 'default' | 'outline'
/** States in which to show this button */
showInStates: ClientToolCallState[]
/**
* Message to send when the action is triggered.
* Used by markToolComplete.
*/
completionMessage?: string
/**
* Target state after action.
* If not provided, defaults to 'background'.
*/
targetState?: ClientToolCallState
}
/**
* Configuration for subagent tools (tools that spawn subagents)
*/
export interface SubagentConfig {
/** Label shown while streaming (e.g., "Planning", "Editing") */
streamingLabel: string
/** Label shown when complete (e.g., "Planned", "Edited") */
completedLabel: string
/**
* Whether the content should collapse when streaming ends.
* Default: true
*/
shouldCollapse?: boolean
/**
* Output artifacts that should NOT be collapsed.
* These are rendered outside the collapsible content.
* Examples: 'plan' for PlanSteps, 'options' for OptionsSelector
*/
outputArtifacts?: Array<'plan' | 'options' | 'edit_summary'>
/**
* Whether this subagent renders its own specialized content
* and the thinking text should be minimal or hidden.
* Used for tools like 'edit' where we show WorkflowEditSummary instead.
*/
hideThinkingText?: boolean
}
/**
* Interrupt button configuration
*/
export interface InterruptButtonConfig {
text: string
icon: LucideIcon
}
/**
* Configuration for interrupt behavior (Run/Skip buttons)
*/
export interface InterruptConfig {
/** Accept button config */
accept: InterruptButtonConfig
/** Reject button config */
reject: InterruptButtonConfig
/**
* Whether to show "Allow Once" button (default accept behavior).
* Default: true
*/
showAllowOnce?: boolean
/**
* Whether to show "Allow Always" button (auto-approve this tool in future).
* Default: true for most tools
*/
showAllowAlways?: boolean
}
/**
* Complete UI configuration for a tool
*/
export interface ToolUIConfig {
/**
* Whether this is a "special" tool that gets gradient styling.
* Used for workflow operation tools like edit_workflow, build_workflow, etc.
*/
isSpecial?: boolean
/**
* Interrupt configuration for tools that require user confirmation.
* If not provided, tool auto-executes.
*/
interrupt?: InterruptConfig
/**
* Secondary action button (like "Move to Background" for run_workflow)
*/
secondaryAction?: SecondaryActionConfig
/**
* Configuration for rendering params as a table.
* If provided, tool will show an expandable/inline table.
*/
paramsTable?: ParamsTableConfig
/**
* Subagent configuration for tools that spawn subagents.
* If provided, tool is treated as a subagent tool.
*/
subagent?: SubagentConfig
/**
* Whether this tool should always show params expanded (not collapsible).
* Used for tools like set_environment_variables that always show their table.
*/
alwaysExpanded?: boolean
/**
* Custom component type for special rendering.
* The tool-call component will use this to render specialized content.
*/
customRenderer?: 'code' | 'edit_summary' | 'none'
}
/**
* Registry of tool UI configurations.
* Tools can register their UI config here for the tool-call component to use.
*/
const toolUIConfigs: Record<string, ToolUIConfig> = {}
/**
* Register a tool's UI configuration
*/
export function registerToolUIConfig(toolName: string, config: ToolUIConfig): void {
toolUIConfigs[toolName] = config
}
/**
* Get a tool's UI configuration
*/
export function getToolUIConfig(toolName: string): ToolUIConfig | undefined {
return toolUIConfigs[toolName]
}
/**
* Check if a tool is a subagent tool
*/
export function isSubagentTool(toolName: string): boolean {
return !!toolUIConfigs[toolName]?.subagent
}
/**
* Check if a tool is a "special" tool (gets gradient styling)
*/
export function isSpecialTool(toolName: string): boolean {
return !!toolUIConfigs[toolName]?.isSpecial
}
/**
* Check if a tool has interrupt (requires user confirmation)
*/
export function hasInterrupt(toolName: string): boolean {
return !!toolUIConfigs[toolName]?.interrupt
}
/**
* Get subagent labels for a tool
*/
export function getSubagentLabels(
toolName: string,
isStreaming: boolean
): { streaming: string; completed: string } | undefined {
const config = toolUIConfigs[toolName]?.subagent
if (!config) return undefined
return {
streaming: config.streamingLabel,
completed: config.completedLabel,
}
}
/**
* Get all registered tool UI configs (for debugging)
*/
export function getAllToolUIConfigs(): Record<string, ToolUIConfig> {
return { ...toolUIConfigs }
}

View File

@@ -1,41 +0,0 @@
import { Key, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class GetCredentialsClientTool extends BaseClientTool {
static readonly id = 'get_credentials'
constructor(toolCallId: string) {
super(toolCallId, GetCredentialsClientTool.id, GetCredentialsClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Fetching connected integrations', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Fetching connected integrations', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Fetching connected integrations', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Fetched connected integrations', icon: Key },
[ClientToolCallState.error]: {
text: 'Failed to fetch connected integrations',
icon: XCircle,
},
[ClientToolCallState.aborted]: {
text: 'Aborted fetching connected integrations',
icon: MinusCircle,
},
[ClientToolCallState.rejected]: {
text: 'Skipped fetching connected integrations',
icon: MinusCircle,
},
},
}
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,126 +0,0 @@
import { createLogger } from '@sim/logger'
import { Loader2, Settings2, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
import { useEnvironmentStore } from '@/stores/settings/environment'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface SetEnvArgs {
variables: Record<string, string>
workflowId?: string
}
export class SetEnvironmentVariablesClientTool extends BaseClientTool {
static readonly id = 'set_environment_variables'
constructor(toolCallId: string) {
super(
toolCallId,
SetEnvironmentVariablesClientTool.id,
SetEnvironmentVariablesClientTool.metadata
)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
text: 'Preparing to set environment variables',
icon: Loader2,
},
[ClientToolCallState.pending]: { text: 'Set environment variables?', icon: Settings2 },
[ClientToolCallState.executing]: { text: 'Setting environment variables', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Set environment variables', icon: Settings2 },
[ClientToolCallState.error]: { text: 'Failed to set environment variables', icon: X },
[ClientToolCallState.aborted]: {
text: 'Aborted setting environment variables',
icon: XCircle,
},
[ClientToolCallState.rejected]: {
text: 'Skipped setting environment variables',
icon: XCircle,
},
},
interrupt: {
accept: { text: 'Apply', icon: Settings2 },
reject: { text: 'Skip', icon: XCircle },
},
uiConfig: {
alwaysExpanded: true,
interrupt: {
accept: { text: 'Apply', icon: Settings2 },
reject: { text: 'Skip', icon: XCircle },
showAllowOnce: true,
showAllowAlways: true,
},
paramsTable: {
columns: [
{ key: 'name', label: 'Variable', width: '36%', editable: true },
{ key: 'value', label: 'Value', width: '64%', editable: true, mono: true },
],
extractRows: (params) => {
const variables = params.variables || {}
const entries = Array.isArray(variables)
? variables.map((v: any, i: number) => [String(i), v.name || `var_${i}`, v.value || ''])
: Object.entries(variables).map(([key, val]) => {
if (typeof val === 'object' && val !== null && 'value' in (val as any)) {
return [key, key, (val as any).value]
}
return [key, key, val]
})
return entries as Array<[string, ...any[]]>
},
},
},
getDynamicText: (params, state) => {
if (params?.variables && typeof params.variables === 'object') {
const count = Object.keys(params.variables).length
const varText = count === 1 ? 'variable' : 'variables'
switch (state) {
case ClientToolCallState.success:
return `Set ${count} ${varText}`
case ClientToolCallState.executing:
return `Setting ${count} ${varText}`
case ClientToolCallState.generating:
return `Preparing to set ${count} ${varText}`
case ClientToolCallState.pending:
return `Set ${count} ${varText}?`
case ClientToolCallState.error:
return `Failed to set ${count} ${varText}`
case ClientToolCallState.aborted:
return `Aborted setting ${count} ${varText}`
case ClientToolCallState.rejected:
return `Skipped setting ${count} ${varText}`
}
}
return undefined
},
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(_args?: SetEnvArgs): Promise<void> {
// Tool execution is handled server-side by the orchestrator.
this.setState(ClientToolCallState.executing)
}
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)
}
}
// Register UI config at module load
registerToolUIConfig(
SetEnvironmentVariablesClientTool.id,
SetEnvironmentVariablesClientTool.metadata.uiConfig!
)

View File

@@ -1,142 +0,0 @@
import {
extractFieldsFromSchema,
parseResponseFormatSafely,
} from '@/lib/core/utils/response-format'
import { getBlockOutputPaths } from '@/lib/workflows/blocks/block-outputs'
import { getBlock } from '@/blocks'
import { normalizeName } from '@/executor/constants'
import { useVariablesStore } from '@/stores/panel/variables/store'
import type { Variable } from '@/stores/panel/variables/types'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
export interface WorkflowContext {
workflowId: string
blocks: Record<string, BlockState>
loops: Record<string, Loop>
parallels: Record<string, Parallel>
subBlockValues: Record<string, Record<string, any>>
}
export interface VariableOutput {
id: string
name: string
type: string
tag: string
}
export function getWorkflowSubBlockValues(workflowId: string): Record<string, Record<string, any>> {
const subBlockStore = useSubBlockStore.getState()
return subBlockStore.workflowValues[workflowId] ?? {}
}
export function getMergedSubBlocks(
blocks: Record<string, BlockState>,
subBlockValues: Record<string, Record<string, any>>,
targetBlockId: string
): Record<string, any> {
const base = blocks[targetBlockId]?.subBlocks || {}
const live = subBlockValues?.[targetBlockId] || {}
const merged: Record<string, any> = { ...base }
for (const [subId, liveVal] of Object.entries(live)) {
merged[subId] = { ...(base[subId] || {}), value: liveVal }
}
return merged
}
export function getSubBlockValue(
blocks: Record<string, BlockState>,
subBlockValues: Record<string, Record<string, any>>,
targetBlockId: string,
subBlockId: string
): any {
const live = subBlockValues?.[targetBlockId]?.[subBlockId]
if (live !== undefined) return live
return blocks[targetBlockId]?.subBlocks?.[subBlockId]?.value
}
export function getWorkflowVariables(workflowId: string): VariableOutput[] {
const getVariablesByWorkflowId = useVariablesStore.getState().getVariablesByWorkflowId
const workflowVariables = getVariablesByWorkflowId(workflowId)
const validVariables = workflowVariables.filter(
(variable: Variable) => variable.name.trim() !== ''
)
return validVariables.map((variable: Variable) => ({
id: variable.id,
name: variable.name,
type: variable.type,
tag: `variable.${normalizeName(variable.name)}`,
}))
}
export function getSubflowInsidePaths(
blockType: 'loop' | 'parallel',
blockId: string,
loops: Record<string, Loop>,
parallels: Record<string, Parallel>
): string[] {
const paths = ['index']
if (blockType === 'loop') {
const loopType = loops[blockId]?.loopType || 'for'
if (loopType === 'forEach') {
paths.push('currentItem', 'items')
}
} else {
const parallelType = parallels[blockId]?.parallelType || 'count'
if (parallelType === 'collection') {
paths.push('currentItem', 'items')
}
}
return paths
}
export function computeBlockOutputPaths(block: BlockState, ctx: WorkflowContext): string[] {
const { blocks, loops, parallels, subBlockValues } = ctx
const blockConfig = getBlock(block.type)
const mergedSubBlocks = getMergedSubBlocks(blocks, subBlockValues, block.id)
if (block.type === 'loop' || block.type === 'parallel') {
const insidePaths = getSubflowInsidePaths(block.type, block.id, loops, parallels)
return ['results', ...insidePaths]
}
if (block.type === 'evaluator') {
const metricsValue = getSubBlockValue(blocks, subBlockValues, block.id, 'metrics')
if (metricsValue && Array.isArray(metricsValue) && metricsValue.length > 0) {
const validMetrics = metricsValue.filter((metric: { name?: string }) => metric?.name)
return validMetrics.map((metric: { name: string }) => metric.name.toLowerCase())
}
return getBlockOutputPaths(block.type, mergedSubBlocks)
}
if (block.type === 'variables') {
const variablesValue = getSubBlockValue(blocks, subBlockValues, block.id, 'variables')
if (variablesValue && Array.isArray(variablesValue) && variablesValue.length > 0) {
const validAssignments = variablesValue.filter((assignment: { variableName?: string }) =>
assignment?.variableName?.trim()
)
return validAssignments.map((assignment: { variableName: string }) =>
assignment.variableName.trim()
)
}
return []
}
if (blockConfig) {
const responseFormatValue = mergedSubBlocks?.responseFormat?.value
const responseFormat = parseResponseFormatSafely(responseFormatValue, block.id)
if (responseFormat) {
const schemaFields = extractFieldsFromSchema(responseFormat)
if (schemaFields.length > 0) {
return schemaFields.map((field) => field.name)
}
}
}
return getBlockOutputPaths(block.type, mergedSubBlocks, block.triggerMode)
}
export function formatOutputsWithPrefix(paths: string[], blockName: string): string[] {
const normalizedName = normalizeName(blockName)
return paths.map((path) => `${normalizedName}.${path}`)
}

View File

@@ -1,215 +0,0 @@
import { createLogger } from '@sim/logger'
import { Loader2, Rocket, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface CheckDeploymentStatusArgs {
workflowId?: string
}
interface ApiDeploymentDetails {
isDeployed: boolean
deployedAt: string | null
endpoint: string | null
apiKey: string | null
needsRedeployment: boolean
}
interface ChatDeploymentDetails {
isDeployed: boolean
chatId: string | null
identifier: string | null
chatUrl: string | null
title: string | null
description: string | null
authType: string | null
allowedEmails: string[] | null
outputConfigs: Array<{ blockId: string; path: string }> | null
welcomeMessage: string | null
primaryColor: string | null
hasPassword: boolean
}
interface McpDeploymentDetails {
isDeployed: boolean
servers: Array<{
serverId: string
serverName: string
toolName: string
toolDescription: string | null
parameterSchema?: Record<string, unknown> | null
toolId?: string | null
}>
}
export class CheckDeploymentStatusClientTool extends BaseClientTool {
static readonly id = 'check_deployment_status'
constructor(toolCallId: string) {
super(toolCallId, CheckDeploymentStatusClientTool.id, CheckDeploymentStatusClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
text: 'Checking deployment status',
icon: Loader2,
},
[ClientToolCallState.pending]: { text: 'Checking deployment status', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Checking deployment status', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Checked deployment status', icon: Rocket },
[ClientToolCallState.error]: { text: 'Failed to check deployment status', icon: X },
[ClientToolCallState.aborted]: {
text: 'Aborted checking deployment status',
icon: XCircle,
},
[ClientToolCallState.rejected]: {
text: 'Skipped checking deployment status',
icon: XCircle,
},
},
interrupt: undefined,
}
async execute(args?: CheckDeploymentStatusArgs): Promise<void> {
const logger = createLogger('CheckDeploymentStatusClientTool')
try {
this.setState(ClientToolCallState.executing)
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
const workflowId = args?.workflowId || activeWorkflowId
if (!workflowId) {
throw new Error('No workflow ID provided')
}
const workflow = workflows[workflowId]
const workspaceId = workflow?.workspaceId
// Fetch deployment status from all sources
const [apiDeployRes, chatDeployRes, mcpServersRes] = await Promise.all([
fetch(`/api/workflows/${workflowId}/deploy`),
fetch(`/api/workflows/${workflowId}/chat/status`),
workspaceId ? fetch(`/api/mcp/workflow-servers?workspaceId=${workspaceId}`) : null,
])
const apiDeploy = apiDeployRes.ok ? await apiDeployRes.json() : null
const chatDeploy = chatDeployRes.ok ? await chatDeployRes.json() : null
const mcpServers = mcpServersRes?.ok ? await mcpServersRes.json() : null
// API deployment details
const isApiDeployed = apiDeploy?.isDeployed || false
const appUrl = typeof window !== 'undefined' ? window.location.origin : ''
const apiDetails: ApiDeploymentDetails = {
isDeployed: isApiDeployed,
deployedAt: apiDeploy?.deployedAt || null,
endpoint: isApiDeployed ? `${appUrl}/api/workflows/${workflowId}/execute` : null,
apiKey: apiDeploy?.apiKey || null,
needsRedeployment: apiDeploy?.needsRedeployment === true,
}
// Chat deployment details
const isChatDeployed = !!(chatDeploy?.isDeployed && chatDeploy?.deployment)
const chatDetails: ChatDeploymentDetails = {
isDeployed: isChatDeployed,
chatId: chatDeploy?.deployment?.id || null,
identifier: chatDeploy?.deployment?.identifier || null,
chatUrl: isChatDeployed ? `${appUrl}/chat/${chatDeploy?.deployment?.identifier}` : null,
title: chatDeploy?.deployment?.title || null,
description: chatDeploy?.deployment?.description || null,
authType: chatDeploy?.deployment?.authType || null,
allowedEmails: Array.isArray(chatDeploy?.deployment?.allowedEmails)
? chatDeploy?.deployment?.allowedEmails
: null,
outputConfigs: Array.isArray(chatDeploy?.deployment?.outputConfigs)
? chatDeploy?.deployment?.outputConfigs
: null,
welcomeMessage: chatDeploy?.deployment?.customizations?.welcomeMessage || null,
primaryColor: chatDeploy?.deployment?.customizations?.primaryColor || null,
hasPassword: chatDeploy?.deployment?.hasPassword === true,
}
// MCP deployment details - find servers that have this workflow as a tool
const mcpServerList = mcpServers?.data?.servers || []
const mcpToolDeployments: McpDeploymentDetails['servers'] = []
for (const server of mcpServerList) {
// Check if this workflow is deployed as a tool on this server
if (server.toolNames && Array.isArray(server.toolNames)) {
// We need to fetch the actual tools to check if this workflow is there
try {
const toolsRes = await fetch(
`/api/mcp/workflow-servers/${server.id}/tools?workspaceId=${workspaceId}`
)
if (toolsRes.ok) {
const toolsData = await toolsRes.json()
const tools = toolsData.data?.tools || []
for (const tool of tools) {
if (tool.workflowId === workflowId) {
mcpToolDeployments.push({
serverId: server.id,
serverName: server.name,
toolName: tool.toolName,
toolDescription: tool.toolDescription,
parameterSchema: tool.parameterSchema ?? null,
toolId: tool.id ?? null,
})
}
}
}
} catch {
// Skip this server if we can't fetch tools
}
}
}
const isMcpDeployed = mcpToolDeployments.length > 0
const mcpDetails: McpDeploymentDetails = {
isDeployed: isMcpDeployed,
servers: mcpToolDeployments,
}
// Build deployment types list
const deploymentTypes: string[] = []
if (isApiDeployed) deploymentTypes.push('api')
if (isChatDeployed) deploymentTypes.push('chat')
if (isMcpDeployed) deploymentTypes.push('mcp')
const isDeployed = isApiDeployed || isChatDeployed || isMcpDeployed
// Build summary message
let message = ''
if (!isDeployed) {
message = 'Workflow is not deployed'
} else {
const parts: string[] = []
if (isApiDeployed) parts.push('API')
if (isChatDeployed) parts.push(`Chat (${chatDetails.identifier})`)
if (isMcpDeployed) {
const serverNames = mcpToolDeployments.map((d) => d.serverName).join(', ')
parts.push(`MCP (${serverNames})`)
}
message = `Workflow is deployed as: ${parts.join(', ')}`
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, message, {
isDeployed,
deploymentTypes,
api: apiDetails,
chat: chatDetails,
mcp: mcpDetails,
})
logger.info('Checked deployment status', { isDeployed, deploymentTypes })
} catch (e: any) {
logger.error('Check deployment status failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to check deployment status')
}
}
}

View File

@@ -1,155 +0,0 @@
import { createLogger } from '@sim/logger'
import { Loader2, Plus, Server, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { useCopilotStore } from '@/stores/panel/copilot/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export interface CreateWorkspaceMcpServerArgs {
/** Name of the MCP server */
name: string
/** Optional description */
description?: string
workspaceId?: string
}
/**
* Create workspace MCP server tool.
* Creates a new MCP server in the workspace that workflows can be deployed to as tools.
*/
export class CreateWorkspaceMcpServerClientTool extends BaseClientTool {
static readonly id = 'create_workspace_mcp_server'
constructor(toolCallId: string) {
super(
toolCallId,
CreateWorkspaceMcpServerClientTool.id,
CreateWorkspaceMcpServerClientTool.metadata
)
}
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
const toolCallsById = useCopilotStore.getState().toolCallsById
const toolCall = toolCallsById[this.toolCallId]
const params = toolCall?.params as CreateWorkspaceMcpServerArgs | undefined
const serverName = params?.name || 'MCP Server'
return {
accept: { text: `Create "${serverName}"`, icon: Plus },
reject: { text: 'Skip', icon: XCircle },
}
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
text: 'Preparing to create MCP server',
icon: Loader2,
},
[ClientToolCallState.pending]: { text: 'Create MCP server?', icon: Server },
[ClientToolCallState.executing]: { text: 'Creating MCP server', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Created MCP server', icon: Server },
[ClientToolCallState.error]: { text: 'Failed to create MCP server', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted creating MCP server', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped creating MCP server', icon: XCircle },
},
interrupt: {
accept: { text: 'Create', icon: Plus },
reject: { text: 'Skip', icon: XCircle },
},
getDynamicText: (params, state) => {
const name = params?.name || 'MCP server'
switch (state) {
case ClientToolCallState.success:
return `Created MCP server "${name}"`
case ClientToolCallState.executing:
return `Creating MCP server "${name}"`
case ClientToolCallState.generating:
return `Preparing to create "${name}"`
case ClientToolCallState.pending:
return `Create MCP server "${name}"?`
case ClientToolCallState.error:
return `Failed to create "${name}"`
}
return undefined
},
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: CreateWorkspaceMcpServerArgs): Promise<void> {
const logger = createLogger('CreateWorkspaceMcpServerClientTool')
try {
if (!args?.name) {
throw new Error('Server name is required')
}
// Get workspace ID from active workflow if not provided
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
let workspaceId = args?.workspaceId
if (!workspaceId && activeWorkflowId) {
workspaceId = workflows[activeWorkflowId]?.workspaceId
}
if (!workspaceId) {
throw new Error('No workspace ID available')
}
this.setState(ClientToolCallState.executing)
const res = await fetch('/api/mcp/workflow-servers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workspaceId,
name: args.name.trim(),
description: args.description?.trim() || null,
}),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || `Failed to create MCP server (${res.status})`)
}
const server = data.data?.server
if (!server) {
throw new Error('Server creation response missing server data')
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(
200,
`MCP server "${args.name}" created successfully. You can now deploy workflows to it using deploy_mcp.`,
{
success: true,
serverId: server.id,
serverName: server.name,
description: server.description,
}
)
logger.info(`Created MCP server: ${server.name} (${server.id})`)
} catch (e: any) {
logger.error('Failed to create MCP server', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to create MCP server', {
success: false,
error: e?.message,
})
}
}
async execute(args?: CreateWorkspaceMcpServerArgs): Promise<void> {
await this.handleAccept(args)
}
}

View File

@@ -1,286 +0,0 @@
import { createLogger } from '@sim/logger'
import { Loader2, Rocket, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getInputFormatExample } from '@/lib/workflows/operations/deployment-utils'
import { useCopilotStore } from '@/stores/panel/copilot/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface DeployApiArgs {
action: 'deploy' | 'undeploy'
workflowId?: string
}
/**
* Deploy API tool for deploying workflows as REST APIs.
* This tool handles both deploying and undeploying workflows via the API endpoint.
*/
export class DeployApiClientTool extends BaseClientTool {
static readonly id = 'deploy_api'
constructor(toolCallId: string) {
super(toolCallId, DeployApiClientTool.id, DeployApiClientTool.metadata)
}
/**
* Override to provide dynamic button text based on action
*/
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
const toolCallsById = useCopilotStore.getState().toolCallsById
const toolCall = toolCallsById[this.toolCallId]
const params = toolCall?.params as DeployApiArgs | undefined
const action = params?.action || 'deploy'
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
const isAlreadyDeployed = workflowId
? useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)?.isDeployed
: false
let buttonText = action === 'undeploy' ? 'Undeploy' : 'Deploy'
if (action === 'deploy' && isAlreadyDeployed) {
buttonText = 'Redeploy'
}
return {
accept: { text: buttonText, icon: Rocket },
reject: { text: 'Skip', icon: XCircle },
}
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
text: 'Preparing to deploy API',
icon: Loader2,
},
[ClientToolCallState.pending]: { text: 'Deploy as API?', icon: Rocket },
[ClientToolCallState.executing]: { text: 'Deploying API', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Deployed API', icon: Rocket },
[ClientToolCallState.error]: { text: 'Failed to deploy API', icon: XCircle },
[ClientToolCallState.aborted]: {
text: 'Aborted deploying API',
icon: XCircle,
},
[ClientToolCallState.rejected]: {
text: 'Skipped deploying API',
icon: XCircle,
},
},
interrupt: {
accept: { text: 'Deploy', icon: Rocket },
reject: { text: 'Skip', icon: XCircle },
},
uiConfig: {
isSpecial: true,
interrupt: {
accept: { text: 'Deploy', icon: Rocket },
reject: { text: 'Skip', icon: XCircle },
showAllowOnce: true,
showAllowAlways: true,
},
},
getDynamicText: (params, state) => {
const action = params?.action === 'undeploy' ? 'undeploy' : 'deploy'
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
const isAlreadyDeployed = workflowId
? useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)?.isDeployed
: false
let actionText = action
let actionTextIng = action === 'undeploy' ? 'undeploying' : 'deploying'
const actionTextPast = action === 'undeploy' ? 'undeployed' : 'deployed'
if (action === 'deploy' && isAlreadyDeployed) {
actionText = 'redeploy'
actionTextIng = 'redeploying'
}
const actionCapitalized = actionText.charAt(0).toUpperCase() + actionText.slice(1)
switch (state) {
case ClientToolCallState.success:
return `API ${actionTextPast}`
case ClientToolCallState.executing:
return `${actionCapitalized}ing API`
case ClientToolCallState.generating:
return `Preparing to ${actionText} API`
case ClientToolCallState.pending:
return `${actionCapitalized} API?`
case ClientToolCallState.error:
return `Failed to ${actionText} API`
case ClientToolCallState.aborted:
return `Aborted ${actionTextIng} API`
case ClientToolCallState.rejected:
return `Skipped ${actionTextIng} API`
}
return undefined
},
}
/**
* Checks if the user has any API keys (workspace or personal)
*/
private async hasApiKeys(workspaceId: string): Promise<boolean> {
try {
const [workspaceRes, personalRes] = await Promise.all([
fetch(`/api/workspaces/${workspaceId}/api-keys`),
fetch('/api/users/me/api-keys'),
])
if (!workspaceRes.ok || !personalRes.ok) {
return false
}
const workspaceData = await workspaceRes.json()
const personalData = await personalRes.json()
const workspaceKeys = (workspaceData?.keys || []) as Array<any>
const personalKeys = (personalData?.keys || []) as Array<any>
return workspaceKeys.length > 0 || personalKeys.length > 0
} catch (error) {
const logger = createLogger('DeployApiClientTool')
logger.warn('Failed to check API keys:', error)
return false
}
}
/**
* Opens the settings modal to the API keys tab
*/
private openApiKeysModal(): void {
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'apikeys' } }))
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: DeployApiArgs): Promise<void> {
const logger = createLogger('DeployApiClientTool')
try {
const action = args?.action || 'deploy'
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
const workflowId = args?.workflowId || activeWorkflowId
if (!workflowId) {
throw new Error('No workflow ID provided')
}
const workflow = workflows[workflowId]
const workspaceId = workflow?.workspaceId
// For deploy action, check if user has API keys first
if (action === 'deploy') {
if (!workspaceId) {
throw new Error('Workflow workspace not found')
}
const hasKeys = await this.hasApiKeys(workspaceId)
if (!hasKeys) {
this.setState(ClientToolCallState.rejected)
this.openApiKeysModal()
await this.markToolComplete(
200,
'Cannot deploy without an API key. Opened API key settings so you can create one. Once you have an API key, try deploying again.',
{
needsApiKey: true,
message:
'You need to create an API key before you can deploy your workflow. The API key settings have been opened for you. After creating an API key, you can deploy your workflow.',
}
)
return
}
}
this.setState(ClientToolCallState.executing)
const endpoint = `/api/workflows/${workflowId}/deploy`
const method = action === 'deploy' ? 'POST' : 'DELETE'
const res = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: action === 'deploy' ? JSON.stringify({ deployChatEnabled: false }) : undefined,
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(txt || `Server error (${res.status})`)
}
const json = await res.json()
let successMessage = ''
let resultData: any = {
action,
isDeployed: action === 'deploy',
deployedAt: json.deployedAt,
}
if (action === 'deploy') {
const appUrl = getBaseUrl()
const apiEndpoint = `${appUrl}/api/workflows/${workflowId}/execute`
const apiKeyPlaceholder = '$SIM_API_KEY'
const inputExample = getInputFormatExample(false)
const curlCommand = `curl -X POST -H "X-API-Key: ${apiKeyPlaceholder}" -H "Content-Type: application/json"${inputExample} ${apiEndpoint}`
successMessage = 'Workflow deployed successfully as API. You can now call it via REST.'
resultData = {
...resultData,
endpoint: apiEndpoint,
curlCommand,
apiKeyPlaceholder,
}
} else {
successMessage = 'Workflow undeployed successfully.'
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, successMessage, resultData)
// Refresh the workflow registry to update deployment status
try {
const setDeploymentStatus = useWorkflowRegistry.getState().setDeploymentStatus
if (action === 'deploy') {
setDeploymentStatus(
workflowId,
true,
json.deployedAt ? new Date(json.deployedAt) : undefined,
json.apiKey || ''
)
} else {
setDeploymentStatus(workflowId, false, undefined, '')
}
const actionPast = action === 'undeploy' ? 'undeployed' : 'deployed'
logger.info(`Workflow ${actionPast} as API and registry updated`)
} catch (error) {
logger.warn('Failed to update workflow registry:', error)
}
} catch (e: any) {
logger.error('Deploy API failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to deploy API')
}
}
async execute(args?: DeployApiArgs): Promise<void> {
await this.handleAccept(args)
}
}
// Register UI config at module load
registerToolUIConfig(DeployApiClientTool.id, DeployApiClientTool.metadata.uiConfig!)

View File

@@ -1,381 +0,0 @@
import { createLogger } from '@sim/logger'
import { Loader2, MessageSquare, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { useCopilotStore } from '@/stores/panel/copilot/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export type ChatAuthType = 'public' | 'password' | 'email' | 'sso'
export interface OutputConfig {
blockId: string
path: string
}
export interface DeployChatArgs {
action: 'deploy' | 'undeploy'
workflowId?: string
/** URL slug for the chat (lowercase letters, numbers, hyphens only) */
identifier?: string
/** Display title for the chat interface */
title?: string
/** Optional description */
description?: string
/** Authentication type: public, password, email, or sso */
authType?: ChatAuthType
/** Password for password-protected chats */
password?: string
/** List of allowed emails/domains for email or SSO auth */
allowedEmails?: string[]
/** Welcome message shown to users */
welcomeMessage?: string
/** Output configurations specifying which block outputs to display in chat */
outputConfigs?: OutputConfig[]
}
/**
* Deploy Chat tool for deploying workflows as chat interfaces.
* This tool handles deploying workflows with chat-specific configuration
* including authentication, customization, and output selection.
*/
export class DeployChatClientTool extends BaseClientTool {
static readonly id = 'deploy_chat'
constructor(toolCallId: string) {
super(toolCallId, DeployChatClientTool.id, DeployChatClientTool.metadata)
}
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
const toolCallsById = useCopilotStore.getState().toolCallsById
const toolCall = toolCallsById[this.toolCallId]
const params = toolCall?.params as DeployChatArgs | undefined
const action = params?.action || 'deploy'
const buttonText = action === 'undeploy' ? 'Undeploy' : 'Deploy Chat'
return {
accept: { text: buttonText, icon: MessageSquare },
reject: { text: 'Skip', icon: XCircle },
}
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
text: 'Preparing to deploy chat',
icon: Loader2,
},
[ClientToolCallState.pending]: { text: 'Deploy as chat?', icon: MessageSquare },
[ClientToolCallState.executing]: { text: 'Deploying chat', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Deployed chat', icon: MessageSquare },
[ClientToolCallState.error]: { text: 'Failed to deploy chat', icon: XCircle },
[ClientToolCallState.aborted]: {
text: 'Aborted deploying chat',
icon: XCircle,
},
[ClientToolCallState.rejected]: {
text: 'Skipped deploying chat',
icon: XCircle,
},
},
interrupt: {
accept: { text: 'Deploy Chat', icon: MessageSquare },
reject: { text: 'Skip', icon: XCircle },
},
uiConfig: {
isSpecial: true,
interrupt: {
accept: { text: 'Deploy Chat', icon: MessageSquare },
reject: { text: 'Skip', icon: XCircle },
showAllowOnce: true,
showAllowAlways: true,
},
},
getDynamicText: (params, state) => {
const action = params?.action === 'undeploy' ? 'undeploy' : 'deploy'
switch (state) {
case ClientToolCallState.success:
return action === 'undeploy' ? 'Chat undeployed' : 'Chat deployed'
case ClientToolCallState.executing:
return action === 'undeploy' ? 'Undeploying chat' : 'Deploying chat'
case ClientToolCallState.generating:
return `Preparing to ${action} chat`
case ClientToolCallState.pending:
return action === 'undeploy' ? 'Undeploy chat?' : 'Deploy as chat?'
case ClientToolCallState.error:
return `Failed to ${action} chat`
case ClientToolCallState.aborted:
return action === 'undeploy' ? 'Aborted undeploying chat' : 'Aborted deploying chat'
case ClientToolCallState.rejected:
return action === 'undeploy' ? 'Skipped undeploying chat' : 'Skipped deploying chat'
}
return undefined
},
}
/**
* Generates a default identifier from the workflow name
*/
private generateIdentifier(workflowName: string): string {
return workflowName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.substring(0, 50)
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: DeployChatArgs): Promise<void> {
const logger = createLogger('DeployChatClientTool')
try {
const action = args?.action || 'deploy'
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
const workflowId = args?.workflowId || activeWorkflowId
if (!workflowId) {
throw new Error('No workflow ID provided')
}
const workflow = workflows[workflowId]
// Handle undeploy action
if (action === 'undeploy') {
this.setState(ClientToolCallState.executing)
// First get the chat deployment ID
const statusRes = await fetch(`/api/workflows/${workflowId}/chat/status`)
if (!statusRes.ok) {
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, 'Failed to check chat deployment status', {
success: false,
action: 'undeploy',
isDeployed: false,
error: 'Failed to check chat deployment status',
errorCode: 'SERVER_ERROR',
})
return
}
const statusJson = await statusRes.json()
if (!statusJson.isDeployed || !statusJson.deployment?.id) {
this.setState(ClientToolCallState.error)
await this.markToolComplete(400, 'No active chat deployment found for this workflow', {
success: false,
action: 'undeploy',
isDeployed: false,
error: 'No active chat deployment found for this workflow',
errorCode: 'VALIDATION_ERROR',
})
return
}
const chatId = statusJson.deployment.id
// Delete the chat deployment
const res = await fetch(`/api/chat/manage/${chatId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
this.setState(ClientToolCallState.error)
await this.markToolComplete(res.status, txt || `Server error (${res.status})`, {
success: false,
action: 'undeploy',
isDeployed: true,
error: txt || 'Failed to undeploy chat',
errorCode: 'SERVER_ERROR',
})
return
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'Chat deployment removed successfully.', {
success: true,
action: 'undeploy',
isDeployed: false,
})
return
}
this.setState(ClientToolCallState.executing)
const statusRes = await fetch(`/api/workflows/${workflowId}/chat/status`)
const statusJson = statusRes.ok ? await statusRes.json() : null
const existingDeployment = statusJson?.deployment || null
const baseIdentifier =
existingDeployment?.identifier || this.generateIdentifier(workflow?.name || 'chat')
const baseTitle = existingDeployment?.title || workflow?.name || 'Chat'
const baseDescription = existingDeployment?.description || ''
const baseAuthType = existingDeployment?.authType || 'public'
const baseWelcomeMessage =
existingDeployment?.customizations?.welcomeMessage || 'Hi there! How can I help you today?'
const basePrimaryColor =
existingDeployment?.customizations?.primaryColor || 'var(--brand-primary-hover-hex)'
const baseAllowedEmails = Array.isArray(existingDeployment?.allowedEmails)
? existingDeployment.allowedEmails
: []
const baseOutputConfigs = Array.isArray(existingDeployment?.outputConfigs)
? existingDeployment.outputConfigs
: []
const identifier = args?.identifier || baseIdentifier
const title = args?.title || baseTitle
const description = args?.description ?? baseDescription
const authType = args?.authType || baseAuthType
const welcomeMessage = args?.welcomeMessage || baseWelcomeMessage
const outputConfigs = args?.outputConfigs || baseOutputConfigs
const allowedEmails = args?.allowedEmails || baseAllowedEmails
const primaryColor = basePrimaryColor
if (!identifier || !title) {
throw new Error('Chat identifier and title are required')
}
if (authType === 'password' && !args?.password && !existingDeployment?.hasPassword) {
throw new Error('Password is required when using password protection')
}
if ((authType === 'email' || authType === 'sso') && allowedEmails.length === 0) {
throw new Error(`At least one email or domain is required when using ${authType} access`)
}
const payload = {
workflowId,
identifier: identifier.trim(),
title: title.trim(),
description: description.trim(),
customizations: {
primaryColor,
welcomeMessage: welcomeMessage.trim(),
},
authType,
password: authType === 'password' ? args?.password : undefined,
allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [],
outputConfigs,
}
const isUpdating = Boolean(existingDeployment?.id)
const endpoint = isUpdating ? `/api/chat/manage/${existingDeployment.id}` : '/api/chat'
const method = isUpdating ? 'PATCH' : 'POST'
const res = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const json = await res.json()
if (!res.ok) {
if (json.error === 'Identifier already in use') {
this.setState(ClientToolCallState.error)
await this.markToolComplete(
400,
`The identifier "${identifier}" is already in use. Please choose a different one.`,
{
success: false,
action: 'deploy',
isDeployed: false,
identifier,
error: `Identifier "${identifier}" is already taken`,
errorCode: 'IDENTIFIER_TAKEN',
}
)
return
}
// Handle validation errors
if (json.code === 'VALIDATION_ERROR') {
this.setState(ClientToolCallState.error)
await this.markToolComplete(400, json.error || 'Validation error', {
success: false,
action: 'deploy',
isDeployed: false,
error: json.error,
errorCode: 'VALIDATION_ERROR',
})
return
}
this.setState(ClientToolCallState.error)
await this.markToolComplete(res.status, json.error || 'Failed to deploy chat', {
success: false,
action: 'deploy',
isDeployed: false,
error: json.error || 'Server error',
errorCode: 'SERVER_ERROR',
})
return
}
if (!json.chatUrl) {
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, 'Response missing chat URL', {
success: false,
action: 'deploy',
isDeployed: false,
error: 'Response missing chat URL',
errorCode: 'SERVER_ERROR',
})
return
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(
200,
`Chat deployed successfully! Available at: ${json.chatUrl}`,
{
success: true,
action: 'deploy',
isDeployed: true,
chatId: json.id,
chatUrl: json.chatUrl,
identifier,
title,
authType,
}
)
// Update the workflow registry to reflect deployment status
// Chat deployment also deploys the API, so we update the registry
try {
const setDeploymentStatus = useWorkflowRegistry.getState().setDeploymentStatus
setDeploymentStatus(workflowId, true, new Date(), '')
logger.info('Workflow deployment status updated in registry')
} catch (error) {
logger.warn('Failed to update workflow registry:', error)
}
logger.info('Chat deployed successfully:', json.chatUrl)
} catch (e: any) {
logger.error('Deploy chat failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to deploy chat', {
success: false,
action: 'deploy',
isDeployed: false,
error: e?.message || 'Failed to deploy chat',
errorCode: 'SERVER_ERROR',
})
}
}
async execute(args?: DeployChatArgs): Promise<void> {
await this.handleAccept(args)
}
}
// Register UI config at module load
registerToolUIConfig(DeployChatClientTool.id, DeployChatClientTool.metadata.uiConfig!)

View File

@@ -1,250 +0,0 @@
import { createLogger } from '@sim/logger'
import { Loader2, Server, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export interface ParameterDescription {
name: string
description: string
}
export interface DeployMcpArgs {
/** The MCP server ID to deploy to (get from list_workspace_mcp_servers) */
serverId: string
/** Optional workflow ID (defaults to active workflow) */
workflowId?: string
/** Custom tool name (defaults to workflow name) */
toolName?: string
/** Custom tool description */
toolDescription?: string
/** Parameter descriptions to include in the schema */
parameterDescriptions?: ParameterDescription[]
}
/**
* Deploy MCP tool.
* Deploys the workflow as an MCP tool to a workspace MCP server.
*/
export class DeployMcpClientTool extends BaseClientTool {
static readonly id = 'deploy_mcp'
constructor(toolCallId: string) {
super(toolCallId, DeployMcpClientTool.id, DeployMcpClientTool.metadata)
}
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
return {
accept: { text: 'Deploy to MCP', icon: Server },
reject: { text: 'Skip', icon: XCircle },
}
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
text: 'Preparing to deploy to MCP',
icon: Loader2,
},
[ClientToolCallState.pending]: { text: 'Deploy to MCP server?', icon: Server },
[ClientToolCallState.executing]: { text: 'Deploying to MCP', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Deployed to MCP', icon: Server },
[ClientToolCallState.error]: { text: 'Failed to deploy to MCP', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted MCP deployment', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped MCP deployment', icon: XCircle },
},
interrupt: {
accept: { text: 'Deploy', icon: Server },
reject: { text: 'Skip', icon: XCircle },
},
uiConfig: {
isSpecial: true,
interrupt: {
accept: { text: 'Deploy', icon: Server },
reject: { text: 'Skip', icon: XCircle },
showAllowOnce: true,
showAllowAlways: true,
},
},
getDynamicText: (params, state) => {
const toolName = params?.toolName || 'workflow'
switch (state) {
case ClientToolCallState.success:
return `Deployed "${toolName}" to MCP`
case ClientToolCallState.executing:
return `Deploying "${toolName}" to MCP`
case ClientToolCallState.generating:
return `Preparing to deploy to MCP`
case ClientToolCallState.pending:
return `Deploy "${toolName}" to MCP?`
case ClientToolCallState.error:
return `Failed to deploy to MCP`
}
return undefined
},
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: DeployMcpArgs): Promise<void> {
const logger = createLogger('DeployMcpClientTool')
try {
if (!args?.serverId) {
throw new Error(
'Server ID is required. Use list_workspace_mcp_servers to get available servers.'
)
}
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
const workflowId = args?.workflowId || activeWorkflowId
if (!workflowId) {
throw new Error('No workflow ID available')
}
const workflow = workflows[workflowId]
const workspaceId = workflow?.workspaceId
if (!workspaceId) {
throw new Error('Workflow workspace not found')
}
// Check if workflow is deployed
const deploymentStatus = useWorkflowRegistry
.getState()
.getWorkflowDeploymentStatus(workflowId)
if (!deploymentStatus?.isDeployed) {
throw new Error(
'Workflow must be deployed before adding as an MCP tool. Use deploy_api first.'
)
}
this.setState(ClientToolCallState.executing)
let parameterSchema: Record<string, unknown> | undefined
if (args?.parameterDescriptions && args.parameterDescriptions.length > 0) {
const properties: Record<string, { description: string }> = {}
for (const param of args.parameterDescriptions) {
properties[param.name] = { description: param.description }
}
parameterSchema = { properties }
}
const res = await fetch(
`/api/mcp/workflow-servers/${args.serverId}/tools?workspaceId=${workspaceId}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflowId,
toolName: args.toolName?.trim(),
toolDescription: args.toolDescription?.trim(),
parameterSchema,
}),
}
)
const data = await res.json()
if (!res.ok) {
if (data.error?.includes('already added')) {
const toolsRes = await fetch(
`/api/mcp/workflow-servers/${args.serverId}/tools?workspaceId=${workspaceId}`
)
const toolsJson = toolsRes.ok ? await toolsRes.json() : null
const tools = toolsJson?.data?.tools || []
const existingTool = tools.find((tool: any) => tool.workflowId === workflowId)
if (!existingTool?.id) {
throw new Error('This workflow is already deployed to this MCP server')
}
const patchRes = await fetch(
`/api/mcp/workflow-servers/${args.serverId}/tools/${existingTool.id}?workspaceId=${workspaceId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
toolName: args.toolName?.trim(),
toolDescription: args.toolDescription?.trim(),
parameterSchema,
}),
}
)
const patchJson = patchRes.ok ? await patchRes.json() : null
if (!patchRes.ok) {
const patchError = patchJson?.error || `Failed to update MCP tool (${patchRes.status})`
throw new Error(patchError)
}
const updatedTool = patchJson?.data?.tool
this.setState(ClientToolCallState.success)
await this.markToolComplete(
200,
`Workflow MCP tool updated to "${updatedTool?.toolName || existingTool.toolName}".`,
{
success: true,
toolId: updatedTool?.id || existingTool.id,
toolName: updatedTool?.toolName || existingTool.toolName,
toolDescription: updatedTool?.toolDescription || existingTool.toolDescription,
serverId: args.serverId,
updated: true,
}
)
logger.info('Updated workflow MCP tool', { toolId: existingTool.id })
return
}
if (data.error?.includes('not deployed')) {
throw new Error('Workflow must be deployed before adding as an MCP tool')
}
if (data.error?.includes('Start block')) {
throw new Error('Workflow must have a Start block to be used as an MCP tool')
}
if (data.error?.includes('Server not found')) {
throw new Error(
'MCP server not found. Use list_workspace_mcp_servers to see available servers.'
)
}
throw new Error(data.error || `Failed to deploy to MCP (${res.status})`)
}
const tool = data.data?.tool
if (!tool) {
throw new Error('Response missing tool data')
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(
200,
`Workflow deployed as MCP tool "${tool.toolName}" to server.`,
{
success: true,
toolId: tool.id,
toolName: tool.toolName,
toolDescription: tool.toolDescription,
serverId: args.serverId,
}
)
logger.info(`Deployed workflow as MCP tool: ${tool.toolName}`)
} catch (e: any) {
logger.error('Failed to deploy to MCP', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to deploy to MCP', {
success: false,
error: e?.message,
})
}
}
async execute(args?: DeployMcpArgs): Promise<void> {
await this.handleAccept(args)
}
}
// Register UI config at module load
registerToolUIConfig(DeployMcpClientTool.id, DeployMcpClientTool.metadata.uiConfig!)

View File

@@ -1,47 +0,0 @@
import { Grid2x2, Grid2x2Check, Grid2x2X, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
export class EditWorkflowClientTool extends BaseClientTool {
static readonly id = 'edit_workflow'
constructor(toolCallId: string) {
super(toolCallId, EditWorkflowClientTool.id, EditWorkflowClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Editing your workflow', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Editing your workflow', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Edited your workflow', icon: Grid2x2Check },
[ClientToolCallState.error]: { text: 'Failed to edit your workflow', icon: XCircle },
[ClientToolCallState.review]: { text: 'Review your workflow changes', icon: Grid2x2 },
[ClientToolCallState.rejected]: { text: 'Rejected workflow changes', icon: Grid2x2X },
[ClientToolCallState.aborted]: { text: 'Aborted editing your workflow', icon: MinusCircle },
[ClientToolCallState.pending]: { text: 'Editing your workflow', icon: Loader2 },
},
uiConfig: {
isSpecial: true,
customRenderer: 'edit_summary',
},
}
async handleAccept(): Promise<void> {
// Diff store calls this after review acceptance.
this.setState(ClientToolCallState.success)
}
async execute(): Promise<void> {
// Tool execution is handled server-side by the orchestrator.
// The store's tool_result SSE handler applies the diff preview
// via diffStore.setProposedChanges() when the result arrives.
this.setState(ClientToolCallState.success)
}
}
// Register UI config at module load
registerToolUIConfig(EditWorkflowClientTool.id, EditWorkflowClientTool.metadata.uiConfig!)

View File

@@ -1,144 +0,0 @@
import { createLogger } from '@sim/logger'
import { Loader2, Tag, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import {
computeBlockOutputPaths,
formatOutputsWithPrefix,
getSubflowInsidePaths,
getWorkflowSubBlockValues,
getWorkflowVariables,
} from '@/lib/copilot/tools/client/workflow/block-output-utils'
import {
GetBlockOutputsResult,
type GetBlockOutputsResultType,
} from '@/lib/copilot/tools/shared/schemas'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('GetBlockOutputsClientTool')
interface GetBlockOutputsArgs {
blockIds?: string[]
}
export class GetBlockOutputsClientTool extends BaseClientTool {
static readonly id = 'get_block_outputs'
constructor(toolCallId: string) {
super(toolCallId, GetBlockOutputsClientTool.id, GetBlockOutputsClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Getting block outputs', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Getting block outputs', icon: Tag },
[ClientToolCallState.executing]: { text: 'Getting block outputs', icon: Loader2 },
[ClientToolCallState.aborted]: { text: 'Aborted getting outputs', icon: XCircle },
[ClientToolCallState.success]: { text: 'Retrieved block outputs', icon: Tag },
[ClientToolCallState.error]: { text: 'Failed to get outputs', icon: X },
[ClientToolCallState.rejected]: { text: 'Skipped getting outputs', icon: XCircle },
},
getDynamicText: (params, state) => {
const blockIds = params?.blockIds
if (blockIds && Array.isArray(blockIds) && blockIds.length > 0) {
const count = blockIds.length
switch (state) {
case ClientToolCallState.success:
return `Retrieved outputs for ${count} block${count > 1 ? 's' : ''}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Getting outputs for ${count} block${count > 1 ? 's' : ''}`
case ClientToolCallState.error:
return `Failed to get outputs for ${count} block${count > 1 ? 's' : ''}`
}
}
return undefined
},
}
async execute(args?: GetBlockOutputsArgs): Promise<void> {
try {
this.setState(ClientToolCallState.executing)
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (!activeWorkflowId) {
await this.markToolComplete(400, 'No active workflow found')
this.setState(ClientToolCallState.error)
return
}
const workflowStore = useWorkflowStore.getState()
const blocks = workflowStore.blocks || {}
const loops = workflowStore.loops || {}
const parallels = workflowStore.parallels || {}
const subBlockValues = getWorkflowSubBlockValues(activeWorkflowId)
const ctx = { workflowId: activeWorkflowId, blocks, loops, parallels, subBlockValues }
const targetBlockIds =
args?.blockIds && args.blockIds.length > 0 ? args.blockIds : Object.keys(blocks)
const blockOutputs: GetBlockOutputsResultType['blocks'] = []
for (const blockId of targetBlockIds) {
const block = blocks[blockId]
if (!block?.type) continue
const blockName = block.name || block.type
const blockOutput: GetBlockOutputsResultType['blocks'][0] = {
blockId,
blockName,
blockType: block.type,
outputs: [],
}
// Include triggerMode if the block is in trigger mode
if (block.triggerMode) {
blockOutput.triggerMode = true
}
if (block.type === 'loop' || block.type === 'parallel') {
const insidePaths = getSubflowInsidePaths(block.type, blockId, loops, parallels)
blockOutput.insideSubflowOutputs = formatOutputsWithPrefix(insidePaths, blockName)
blockOutput.outsideSubflowOutputs = formatOutputsWithPrefix(['results'], blockName)
} else {
const outputPaths = computeBlockOutputPaths(block, ctx)
blockOutput.outputs = formatOutputsWithPrefix(outputPaths, blockName)
}
blockOutputs.push(blockOutput)
}
const includeVariables = !args?.blockIds || args.blockIds.length === 0
const resultData: {
blocks: typeof blockOutputs
variables?: ReturnType<typeof getWorkflowVariables>
} = {
blocks: blockOutputs,
}
if (includeVariables) {
resultData.variables = getWorkflowVariables(activeWorkflowId)
}
const result = GetBlockOutputsResult.parse(resultData)
logger.info('Retrieved block outputs', {
blockCount: blockOutputs.length,
variableCount: resultData.variables?.length ?? 0,
})
await this.markToolComplete(200, 'Retrieved block outputs', result)
this.setState(ClientToolCallState.success)
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
logger.error('Error in tool execution', { toolCallId: this.toolCallId, error, message })
await this.markToolComplete(500, message || 'Failed to get block outputs')
this.setState(ClientToolCallState.error)
}
}
}

View File

@@ -1,231 +0,0 @@
import { createLogger } from '@sim/logger'
import { GitBranch, Loader2, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import {
computeBlockOutputPaths,
formatOutputsWithPrefix,
getSubflowInsidePaths,
getWorkflowSubBlockValues,
getWorkflowVariables,
} from '@/lib/copilot/tools/client/workflow/block-output-utils'
import {
GetBlockUpstreamReferencesResult,
type GetBlockUpstreamReferencesResultType,
} from '@/lib/copilot/tools/shared/schemas'
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
const logger = createLogger('GetBlockUpstreamReferencesClientTool')
interface GetBlockUpstreamReferencesArgs {
blockIds: string[]
}
export class GetBlockUpstreamReferencesClientTool extends BaseClientTool {
static readonly id = 'get_block_upstream_references'
constructor(toolCallId: string) {
super(
toolCallId,
GetBlockUpstreamReferencesClientTool.id,
GetBlockUpstreamReferencesClientTool.metadata
)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Getting upstream references', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Getting upstream references', icon: GitBranch },
[ClientToolCallState.executing]: { text: 'Getting upstream references', icon: Loader2 },
[ClientToolCallState.aborted]: { text: 'Aborted getting references', icon: XCircle },
[ClientToolCallState.success]: { text: 'Retrieved upstream references', icon: GitBranch },
[ClientToolCallState.error]: { text: 'Failed to get references', icon: X },
[ClientToolCallState.rejected]: { text: 'Skipped getting references', icon: XCircle },
},
getDynamicText: (params, state) => {
const blockIds = params?.blockIds
if (blockIds && Array.isArray(blockIds) && blockIds.length > 0) {
const count = blockIds.length
switch (state) {
case ClientToolCallState.success:
return `Retrieved references for ${count} block${count > 1 ? 's' : ''}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Getting references for ${count} block${count > 1 ? 's' : ''}`
case ClientToolCallState.error:
return `Failed to get references for ${count} block${count > 1 ? 's' : ''}`
}
}
return undefined
},
}
async execute(args?: GetBlockUpstreamReferencesArgs): Promise<void> {
try {
this.setState(ClientToolCallState.executing)
if (!args?.blockIds || args.blockIds.length === 0) {
await this.markToolComplete(400, 'blockIds array is required')
this.setState(ClientToolCallState.error)
return
}
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (!activeWorkflowId) {
await this.markToolComplete(400, 'No active workflow found')
this.setState(ClientToolCallState.error)
return
}
const workflowStore = useWorkflowStore.getState()
const blocks = workflowStore.blocks || {}
const edges = workflowStore.edges || []
const loops = workflowStore.loops || {}
const parallels = workflowStore.parallels || {}
const subBlockValues = getWorkflowSubBlockValues(activeWorkflowId)
const ctx = { workflowId: activeWorkflowId, blocks, loops, parallels, subBlockValues }
const variableOutputs = getWorkflowVariables(activeWorkflowId)
const graphEdges = edges.map((edge) => ({ source: edge.source, target: edge.target }))
const results: GetBlockUpstreamReferencesResultType['results'] = []
for (const blockId of args.blockIds) {
const targetBlock = blocks[blockId]
if (!targetBlock) {
logger.warn(`Block ${blockId} not found`)
continue
}
const insideSubflows: { blockId: string; blockName: string; blockType: string }[] = []
const containingLoopIds = new Set<string>()
const containingParallelIds = new Set<string>()
Object.values(loops as Record<string, Loop>).forEach((loop) => {
if (loop?.nodes?.includes(blockId)) {
containingLoopIds.add(loop.id)
const loopBlock = blocks[loop.id]
if (loopBlock) {
insideSubflows.push({
blockId: loop.id,
blockName: loopBlock.name || loopBlock.type,
blockType: 'loop',
})
}
}
})
Object.values(parallels as Record<string, Parallel>).forEach((parallel) => {
if (parallel?.nodes?.includes(blockId)) {
containingParallelIds.add(parallel.id)
const parallelBlock = blocks[parallel.id]
if (parallelBlock) {
insideSubflows.push({
blockId: parallel.id,
blockName: parallelBlock.name || parallelBlock.type,
blockType: 'parallel',
})
}
}
})
const ancestorIds = BlockPathCalculator.findAllPathNodes(graphEdges, blockId)
const accessibleIds = new Set<string>(ancestorIds)
accessibleIds.add(blockId)
const starterBlock = Object.values(blocks).find((b) => isInputDefinitionTrigger(b.type))
if (starterBlock && ancestorIds.includes(starterBlock.id)) {
accessibleIds.add(starterBlock.id)
}
containingLoopIds.forEach((loopId) => {
accessibleIds.add(loopId)
loops[loopId]?.nodes?.forEach((nodeId) => accessibleIds.add(nodeId))
})
containingParallelIds.forEach((parallelId) => {
accessibleIds.add(parallelId)
parallels[parallelId]?.nodes?.forEach((nodeId) => accessibleIds.add(nodeId))
})
const accessibleBlocks: GetBlockUpstreamReferencesResultType['results'][0]['accessibleBlocks'] =
[]
for (const accessibleBlockId of accessibleIds) {
const block = blocks[accessibleBlockId]
if (!block?.type) continue
const canSelfReference = block.type === 'approval' || block.type === 'human_in_the_loop'
if (accessibleBlockId === blockId && !canSelfReference) continue
const blockName = block.name || block.type
let accessContext: 'inside' | 'outside' | undefined
let outputPaths: string[]
if (block.type === 'loop' || block.type === 'parallel') {
const isInside =
(block.type === 'loop' && containingLoopIds.has(accessibleBlockId)) ||
(block.type === 'parallel' && containingParallelIds.has(accessibleBlockId))
accessContext = isInside ? 'inside' : 'outside'
outputPaths = isInside
? getSubflowInsidePaths(block.type, accessibleBlockId, loops, parallels)
: ['results']
} else {
outputPaths = computeBlockOutputPaths(block, ctx)
}
const formattedOutputs = formatOutputsWithPrefix(outputPaths, blockName)
const entry: GetBlockUpstreamReferencesResultType['results'][0]['accessibleBlocks'][0] = {
blockId: accessibleBlockId,
blockName,
blockType: block.type,
outputs: formattedOutputs,
}
// Include triggerMode if the block is in trigger mode
if (block.triggerMode) {
entry.triggerMode = true
}
if (accessContext) entry.accessContext = accessContext
accessibleBlocks.push(entry)
}
const resultEntry: GetBlockUpstreamReferencesResultType['results'][0] = {
blockId,
blockName: targetBlock.name || targetBlock.type,
accessibleBlocks,
variables: variableOutputs,
}
if (insideSubflows.length > 0) resultEntry.insideSubflows = insideSubflows
results.push(resultEntry)
}
const result = GetBlockUpstreamReferencesResult.parse({ results })
logger.info('Retrieved upstream references', {
blockIds: args.blockIds,
resultCount: results.length,
})
await this.markToolComplete(200, 'Retrieved upstream references', result)
this.setState(ClientToolCallState.success)
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
logger.error('Error in tool execution', { toolCallId: this.toolCallId, error, message })
await this.markToolComplete(500, message || 'Failed to get upstream references')
this.setState(ClientToolCallState.error)
}
}
}

View File

@@ -1,187 +0,0 @@
import { createLogger } from '@sim/logger'
import { Loader2, Workflow as WorkflowIcon, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { stripWorkflowDiffMarkers } from '@/lib/workflows/diff'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
interface GetUserWorkflowArgs {
workflowId?: string
includeMetadata?: boolean
}
const logger = createLogger('GetUserWorkflowClientTool')
export class GetUserWorkflowClientTool extends BaseClientTool {
static readonly id = 'get_user_workflow'
constructor(toolCallId: string) {
super(toolCallId, GetUserWorkflowClientTool.id, GetUserWorkflowClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Reading your workflow', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Reading your workflow', icon: WorkflowIcon },
[ClientToolCallState.executing]: { text: 'Reading your workflow', icon: Loader2 },
[ClientToolCallState.aborted]: { text: 'Aborted reading your workflow', icon: XCircle },
[ClientToolCallState.success]: { text: 'Read your workflow', icon: WorkflowIcon },
[ClientToolCallState.error]: { text: 'Failed to read your workflow', icon: X },
[ClientToolCallState.rejected]: { text: 'Skipped reading your workflow', icon: XCircle },
},
getDynamicText: (params, state) => {
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
if (workflowId) {
const workflowName = useWorkflowRegistry.getState().workflows[workflowId]?.name
if (workflowName) {
switch (state) {
case ClientToolCallState.success:
return `Read ${workflowName}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Reading ${workflowName}`
case ClientToolCallState.error:
return `Failed to read ${workflowName}`
case ClientToolCallState.aborted:
return `Aborted reading ${workflowName}`
case ClientToolCallState.rejected:
return `Skipped reading ${workflowName}`
}
}
}
return undefined
},
}
async execute(args?: GetUserWorkflowArgs): Promise<void> {
try {
this.setState(ClientToolCallState.executing)
// Determine workflow ID (explicit or active)
let workflowId = args?.workflowId
if (!workflowId) {
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (!activeWorkflowId) {
await this.markToolComplete(400, 'No active workflow found')
this.setState(ClientToolCallState.error)
return
}
workflowId = activeWorkflowId as any
}
logger.info('Fetching user workflow from stores', {
workflowId,
includeMetadata: args?.includeMetadata,
})
// Always use main workflow store as the source of truth
const workflowStore = useWorkflowStore.getState()
const fullWorkflowState = workflowStore.getWorkflowState()
let workflowState: any = null
if (!fullWorkflowState || !fullWorkflowState.blocks) {
const workflowRegistry = useWorkflowRegistry.getState()
const wfKey = String(workflowId)
const workflow = (workflowRegistry as any).workflows?.[wfKey]
if (!workflow) {
await this.markToolComplete(404, `Workflow ${workflowId} not found in any store`)
this.setState(ClientToolCallState.error)
return
}
logger.warn('No workflow state found, using workflow metadata only', { workflowId })
workflowState = workflow
} else {
workflowState = stripWorkflowDiffMarkers(fullWorkflowState)
logger.info('Using workflow state from workflow store', {
workflowId,
blockCount: Object.keys(fullWorkflowState.blocks || {}).length,
})
}
// Normalize required properties
if (workflowState) {
if (!workflowState.loops) workflowState.loops = {}
if (!workflowState.parallels) workflowState.parallels = {}
if (!workflowState.edges) workflowState.edges = []
if (!workflowState.blocks) workflowState.blocks = {}
}
// Merge latest subblock values so edits are reflected
try {
if (workflowState?.blocks) {
workflowState = {
...workflowState,
blocks: mergeSubblockState(workflowState.blocks, workflowId as any),
}
logger.info('Merged subblock values into workflow state', {
workflowId,
blockCount: Object.keys(workflowState.blocks || {}).length,
})
}
} catch (mergeError) {
logger.warn('Failed to merge subblock values; proceeding with raw workflow state', {
workflowId,
error: mergeError instanceof Error ? mergeError.message : String(mergeError),
})
}
logger.info('Validating workflow state', {
workflowId,
hasWorkflowState: !!workflowState,
hasBlocks: !!workflowState?.blocks,
workflowStateType: typeof workflowState,
})
if (!workflowState || !workflowState.blocks) {
await this.markToolComplete(422, 'Workflow state is empty or invalid')
this.setState(ClientToolCallState.error)
return
}
// Sanitize workflow state for copilot (remove UI-specific data)
const sanitizedState = sanitizeForCopilot(workflowState)
// Convert to JSON string for transport
let workflowJson = ''
try {
workflowJson = JSON.stringify(sanitizedState, null, 2)
logger.info('Successfully stringified sanitized workflow state', {
workflowId,
jsonLength: workflowJson.length,
})
} catch (stringifyError) {
await this.markToolComplete(
500,
`Failed to convert workflow to JSON: ${
stringifyError instanceof Error ? stringifyError.message : 'Unknown error'
}`
)
this.setState(ClientToolCallState.error)
return
}
// Mark complete with data; keep state success for store render
await this.markToolComplete(200, 'Workflow analyzed', { userWorkflow: workflowJson })
this.setState(ClientToolCallState.success)
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
logger.error('Error in tool execution', {
toolCallId: this.toolCallId,
error,
message,
})
await this.markToolComplete(500, message || 'Failed to fetch workflow')
this.setState(ClientToolCallState.error)
}
}
}

View File

@@ -1,60 +0,0 @@
import { Loader2, MinusCircle, TerminalSquare, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class GetWorkflowConsoleClientTool extends BaseClientTool {
static readonly id = 'get_workflow_console'
constructor(toolCallId: string) {
super(toolCallId, GetWorkflowConsoleClientTool.id, GetWorkflowConsoleClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Fetching execution logs', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Fetching execution logs', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Fetched execution logs', icon: TerminalSquare },
[ClientToolCallState.error]: { text: 'Failed to fetch execution logs', icon: XCircle },
[ClientToolCallState.rejected]: {
text: 'Skipped fetching execution logs',
icon: MinusCircle,
},
[ClientToolCallState.aborted]: {
text: 'Aborted fetching execution logs',
icon: MinusCircle,
},
[ClientToolCallState.pending]: { text: 'Fetching execution logs', icon: Loader2 },
},
getDynamicText: (params, state) => {
const limit = params?.limit
if (limit && typeof limit === 'number') {
const logText = limit === 1 ? 'execution log' : 'execution logs'
switch (state) {
case ClientToolCallState.success:
return `Fetched last ${limit} ${logText}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Fetching last ${limit} ${logText}`
case ClientToolCallState.error:
return `Failed to fetch last ${limit} ${logText}`
case ClientToolCallState.rejected:
return `Skipped fetching last ${limit} ${logText}`
case ClientToolCallState.aborted:
return `Aborted fetching last ${limit} ${logText}`
}
}
return undefined
},
}
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,269 +0,0 @@
import { createLogger } from '@sim/logger'
import { Database, Loader2, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('GetWorkflowDataClientTool')
/** Data type enum for the get_workflow_data tool */
export type WorkflowDataType = 'global_variables' | 'custom_tools' | 'mcp_tools' | 'files'
interface GetWorkflowDataArgs {
data_type: WorkflowDataType
}
export class GetWorkflowDataClientTool extends BaseClientTool {
static readonly id = 'get_workflow_data'
constructor(toolCallId: string) {
super(toolCallId, GetWorkflowDataClientTool.id, GetWorkflowDataClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Fetching workflow data', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Fetching workflow data', icon: Database },
[ClientToolCallState.executing]: { text: 'Fetching workflow data', icon: Loader2 },
[ClientToolCallState.aborted]: { text: 'Aborted fetching data', icon: XCircle },
[ClientToolCallState.success]: { text: 'Retrieved workflow data', icon: Database },
[ClientToolCallState.error]: { text: 'Failed to fetch data', icon: X },
[ClientToolCallState.rejected]: { text: 'Skipped fetching data', icon: XCircle },
},
getDynamicText: (params, state) => {
const dataType = params?.data_type as WorkflowDataType | undefined
if (!dataType) return undefined
const typeLabels: Record<WorkflowDataType, string> = {
global_variables: 'variables',
custom_tools: 'custom tools',
mcp_tools: 'MCP tools',
files: 'files',
}
const label = typeLabels[dataType] || dataType
switch (state) {
case ClientToolCallState.success:
return `Retrieved ${label}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
return `Fetching ${label}`
case ClientToolCallState.pending:
return `Fetch ${label}?`
case ClientToolCallState.error:
return `Failed to fetch ${label}`
case ClientToolCallState.aborted:
return `Aborted fetching ${label}`
case ClientToolCallState.rejected:
return `Skipped fetching ${label}`
}
return undefined
},
}
async execute(args?: GetWorkflowDataArgs): Promise<void> {
try {
this.setState(ClientToolCallState.executing)
const dataType = args?.data_type
if (!dataType) {
await this.markToolComplete(400, 'Missing data_type parameter')
this.setState(ClientToolCallState.error)
return
}
const { activeWorkflowId, hydration } = useWorkflowRegistry.getState()
const activeWorkspaceId = hydration.workspaceId
switch (dataType) {
case 'global_variables':
await this.fetchGlobalVariables(activeWorkflowId)
break
case 'custom_tools':
await this.fetchCustomTools(activeWorkspaceId)
break
case 'mcp_tools':
await this.fetchMcpTools(activeWorkspaceId)
break
case 'files':
await this.fetchFiles(activeWorkspaceId)
break
default:
await this.markToolComplete(400, `Unknown data_type: ${dataType}`)
this.setState(ClientToolCallState.error)
return
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
await this.markToolComplete(500, message || 'Failed to fetch workflow data')
this.setState(ClientToolCallState.error)
}
}
/**
* Fetch global workflow variables
*/
private async fetchGlobalVariables(workflowId: string | null): Promise<void> {
if (!workflowId) {
await this.markToolComplete(400, 'No active workflow found')
this.setState(ClientToolCallState.error)
return
}
const res = await fetch(`/api/workflows/${workflowId}/variables`, { method: 'GET' })
if (!res.ok) {
const text = await res.text().catch(() => '')
await this.markToolComplete(res.status, text || 'Failed to fetch workflow variables')
this.setState(ClientToolCallState.error)
return
}
const json = await res.json()
const varsRecord = (json?.data as Record<string, unknown>) || {}
const variables = Object.values(varsRecord).map((v: unknown) => {
const variable = v as { id?: string; name?: string; value?: unknown }
return {
id: String(variable?.id || ''),
name: String(variable?.name || ''),
value: variable?.value,
}
})
logger.info('Fetched workflow variables', { count: variables.length })
await this.markToolComplete(200, `Found ${variables.length} variable(s)`, { variables })
this.setState(ClientToolCallState.success)
}
/**
* Fetch custom tools for the workspace
*/
private async fetchCustomTools(workspaceId: string | null): Promise<void> {
if (!workspaceId) {
await this.markToolComplete(400, 'No active workspace found')
this.setState(ClientToolCallState.error)
return
}
const res = await fetch(`/api/tools/custom?workspaceId=${workspaceId}`, { method: 'GET' })
if (!res.ok) {
const text = await res.text().catch(() => '')
await this.markToolComplete(res.status, text || 'Failed to fetch custom tools')
this.setState(ClientToolCallState.error)
return
}
const json = await res.json()
const toolsData = (json?.data as unknown[]) || []
const customTools = toolsData.map((tool: unknown) => {
const t = tool as {
id?: string
title?: string
schema?: { function?: { name?: string; description?: string; parameters?: unknown } }
code?: string
}
return {
id: String(t?.id || ''),
title: String(t?.title || ''),
functionName: String(t?.schema?.function?.name || ''),
description: String(t?.schema?.function?.description || ''),
parameters: t?.schema?.function?.parameters,
}
})
logger.info('Fetched custom tools', { count: customTools.length })
await this.markToolComplete(200, `Found ${customTools.length} custom tool(s)`, { customTools })
this.setState(ClientToolCallState.success)
}
/**
* Fetch MCP tools for the workspace
*/
private async fetchMcpTools(workspaceId: string | null): Promise<void> {
if (!workspaceId) {
await this.markToolComplete(400, 'No active workspace found')
this.setState(ClientToolCallState.error)
return
}
const res = await fetch(`/api/mcp/tools/discover?workspaceId=${workspaceId}`, { method: 'GET' })
if (!res.ok) {
const text = await res.text().catch(() => '')
await this.markToolComplete(res.status, text || 'Failed to fetch MCP tools')
this.setState(ClientToolCallState.error)
return
}
const json = await res.json()
const toolsData = (json?.data?.tools as unknown[]) || []
const mcpTools = toolsData.map((tool: unknown) => {
const t = tool as {
name?: string
serverId?: string
serverName?: string
description?: string
inputSchema?: unknown
}
return {
name: String(t?.name || ''),
serverId: String(t?.serverId || ''),
serverName: String(t?.serverName || ''),
description: String(t?.description || ''),
inputSchema: t?.inputSchema,
}
})
logger.info('Fetched MCP tools', { count: mcpTools.length })
await this.markToolComplete(200, `Found ${mcpTools.length} MCP tool(s)`, { mcpTools })
this.setState(ClientToolCallState.success)
}
/**
* Fetch workspace files metadata
*/
private async fetchFiles(workspaceId: string | null): Promise<void> {
if (!workspaceId) {
await this.markToolComplete(400, 'No active workspace found')
this.setState(ClientToolCallState.error)
return
}
const res = await fetch(`/api/workspaces/${workspaceId}/files`, { method: 'GET' })
if (!res.ok) {
const text = await res.text().catch(() => '')
await this.markToolComplete(res.status, text || 'Failed to fetch files')
this.setState(ClientToolCallState.error)
return
}
const json = await res.json()
const filesData = (json?.files as unknown[]) || []
const files = filesData.map((file: unknown) => {
const f = file as {
id?: string
name?: string
key?: string
path?: string
size?: number
type?: string
uploadedAt?: string
}
return {
id: String(f?.id || ''),
name: String(f?.name || ''),
key: String(f?.key || ''),
path: String(f?.path || ''),
size: Number(f?.size || 0),
type: String(f?.type || ''),
uploadedAt: String(f?.uploadedAt || ''),
}
})
logger.info('Fetched workspace files', { count: files.length })
await this.markToolComplete(200, `Found ${files.length} file(s)`, { files })
this.setState(ClientToolCallState.success)
}
}

View File

@@ -1,117 +0,0 @@
import { createLogger } from '@sim/logger'
import { FileText, Loader2, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import {
formatWorkflowStateForCopilot,
normalizeWorkflowName,
} from '@/lib/copilot/tools/shared/workflow-utils'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('GetWorkflowFromNameClientTool')
interface GetWorkflowFromNameArgs {
workflow_name: string
}
export class GetWorkflowFromNameClientTool extends BaseClientTool {
static readonly id = 'get_workflow_from_name'
constructor(toolCallId: string) {
super(toolCallId, GetWorkflowFromNameClientTool.id, GetWorkflowFromNameClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Reading workflow', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Reading workflow', icon: FileText },
[ClientToolCallState.executing]: { text: 'Reading workflow', icon: Loader2 },
[ClientToolCallState.aborted]: { text: 'Aborted reading workflow', icon: XCircle },
[ClientToolCallState.success]: { text: 'Read workflow', icon: FileText },
[ClientToolCallState.error]: { text: 'Failed to read workflow', icon: X },
[ClientToolCallState.rejected]: { text: 'Skipped reading workflow', icon: XCircle },
},
getDynamicText: (params, state) => {
if (params?.workflow_name && typeof params.workflow_name === 'string') {
const workflowName = params.workflow_name
switch (state) {
case ClientToolCallState.success:
return `Read ${workflowName}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Reading ${workflowName}`
case ClientToolCallState.error:
return `Failed to read ${workflowName}`
case ClientToolCallState.aborted:
return `Aborted reading ${workflowName}`
case ClientToolCallState.rejected:
return `Skipped reading ${workflowName}`
}
}
return undefined
},
}
async execute(args?: GetWorkflowFromNameArgs): Promise<void> {
try {
this.setState(ClientToolCallState.executing)
const workflowName = args?.workflow_name?.trim()
if (!workflowName) {
await this.markToolComplete(400, 'workflow_name is required')
this.setState(ClientToolCallState.error)
return
}
// 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) => normalizeWorkflowName(w?.name) === targetName
) as any
if (!match?.id) {
await this.markToolComplete(404, `Workflow not found: ${workflowName}`)
this.setState(ClientToolCallState.error)
return
}
// Fetch full workflow from API route (normalized tables)
const res = await fetch(`/api/workflows/${encodeURIComponent(match.id)}`, { method: 'GET' })
if (!res.ok) {
const text = await res.text().catch(() => '')
await this.markToolComplete(res.status, text || 'Failed to fetch workflow by name')
this.setState(ClientToolCallState.error)
return
}
const json = await res.json()
const wf = json?.data
if (!wf?.state?.blocks) {
await this.markToolComplete(422, 'Workflow state is empty or invalid')
this.setState(ClientToolCallState.error)
return
}
// Convert state to the same string format as get_user_workflow
const userWorkflow = formatWorkflowStateForCopilot({
blocks: wf.state.blocks || {},
edges: wf.state.edges || [],
loops: wf.state.loops || {},
parallels: wf.state.parallels || {},
})
await this.markToolComplete(200, `Retrieved workflow ${workflowName}`, { userWorkflow })
this.setState(ClientToolCallState.success)
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
await this.markToolComplete(500, message || 'Failed to retrieve workflow by name')
this.setState(ClientToolCallState.error)
}
}
}

View File

@@ -1,59 +0,0 @@
import { createLogger } from '@sim/logger'
import { ListChecks, Loader2, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { extractWorkflowNames } from '@/lib/copilot/tools/shared/workflow-utils'
const logger = createLogger('ListUserWorkflowsClientTool')
export class ListUserWorkflowsClientTool extends BaseClientTool {
static readonly id = 'list_user_workflows'
constructor(toolCallId: string) {
super(toolCallId, ListUserWorkflowsClientTool.id, ListUserWorkflowsClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Listing your workflows', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Listing your workflows', icon: ListChecks },
[ClientToolCallState.executing]: { text: 'Listing your workflows', icon: Loader2 },
[ClientToolCallState.aborted]: { text: 'Aborted listing workflows', icon: XCircle },
[ClientToolCallState.success]: { text: 'Listed your workflows', icon: ListChecks },
[ClientToolCallState.error]: { text: 'Failed to list workflows', icon: X },
[ClientToolCallState.rejected]: { text: 'Skipped listing workflows', icon: XCircle },
},
}
async execute(): Promise<void> {
try {
this.setState(ClientToolCallState.executing)
const res = await fetch('/api/workflows', { method: 'GET' })
if (!res.ok) {
const text = await res.text().catch(() => '')
await this.markToolComplete(res.status, text || 'Failed to fetch workflows')
this.setState(ClientToolCallState.error)
return
}
const json = await res.json()
const workflows = Array.isArray(json?.data) ? json.data : []
const names = extractWorkflowNames(workflows)
logger.info('Found workflows', { count: names.length })
await this.markToolComplete(200, `Found ${names.length} workflow(s)`, {
workflow_names: names,
})
this.setState(ClientToolCallState.success)
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
await this.markToolComplete(500, message || 'Failed to list workflows')
this.setState(ClientToolCallState.error)
}
}
}

View File

@@ -1,112 +0,0 @@
import { createLogger } from '@sim/logger'
import { Loader2, Server, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface ListWorkspaceMcpServersArgs {
workspaceId?: string
}
export interface WorkspaceMcpServer {
id: string
name: string
description: string | null
toolCount: number
toolNames: string[]
}
/**
* List workspace MCP servers tool.
* Returns a list of MCP servers available in the workspace that workflows can be deployed to.
*/
export class ListWorkspaceMcpServersClientTool extends BaseClientTool {
static readonly id = 'list_workspace_mcp_servers'
constructor(toolCallId: string) {
super(
toolCallId,
ListWorkspaceMcpServersClientTool.id,
ListWorkspaceMcpServersClientTool.metadata
)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
text: 'Getting MCP servers',
icon: Loader2,
},
[ClientToolCallState.pending]: { text: 'Getting MCP servers', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Getting MCP servers', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Retrieved MCP servers', icon: Server },
[ClientToolCallState.error]: { text: 'Failed to get MCP servers', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted getting MCP servers', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped getting MCP servers', icon: XCircle },
},
interrupt: undefined,
}
async execute(args?: ListWorkspaceMcpServersArgs): Promise<void> {
const logger = createLogger('ListWorkspaceMcpServersClientTool')
try {
this.setState(ClientToolCallState.executing)
// Get workspace ID from active workflow if not provided
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
let workspaceId = args?.workspaceId
if (!workspaceId && activeWorkflowId) {
workspaceId = workflows[activeWorkflowId]?.workspaceId
}
if (!workspaceId) {
throw new Error('No workspace ID available')
}
const res = await fetch(`/api/mcp/workflow-servers?workspaceId=${workspaceId}`)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || `Failed to fetch MCP servers (${res.status})`)
}
const data = await res.json()
const servers: WorkspaceMcpServer[] = (data.data?.servers || []).map((s: any) => ({
id: s.id,
name: s.name,
description: s.description,
toolCount: s.toolCount || 0,
toolNames: s.toolNames || [],
}))
this.setState(ClientToolCallState.success)
if (servers.length === 0) {
await this.markToolComplete(
200,
'No MCP servers found in this workspace. Use create_workspace_mcp_server to create one.',
{ servers: [], count: 0 }
)
} else {
await this.markToolComplete(
200,
`Found ${servers.length} MCP server(s) in the workspace.`,
{
servers,
count: servers.length,
}
)
}
logger.info(`Listed ${servers.length} MCP servers`)
} catch (e: any) {
logger.error('Failed to list MCP servers', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to list MCP servers')
}
}
}

View File

@@ -1,408 +0,0 @@
import { createLogger } from '@sim/logger'
import { Check, Loader2, Plus, X, XCircle } from 'lucide-react'
import { client } from '@/lib/auth/auth-client'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { getCustomTool } from '@/hooks/queries/custom-tools'
import { useCopilotStore } from '@/stores/panel/copilot/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface CustomToolSchema {
type: 'function'
function: {
name: string
description?: string
parameters: {
type: string
properties: Record<string, any>
required?: string[]
}
}
}
interface ManageCustomToolArgs {
operation: 'add' | 'edit' | 'delete' | 'list'
toolId?: string
schema?: CustomToolSchema
code?: string
}
const API_ENDPOINT = '/api/tools/custom'
async function checkCustomToolsPermission(): Promise<void> {
const activeOrgResponse = await client.organization.getFullOrganization()
const organizationId = activeOrgResponse.data?.id
if (!organizationId) return
const response = await fetch(`/api/permission-groups/user?organizationId=${organizationId}`)
if (!response.ok) return
const data = await response.json()
if (data?.config?.disableCustomTools) {
throw new Error('Custom tools are not allowed based on your permission group settings')
}
}
/**
* Client tool for creating, editing, and deleting custom tools via the copilot.
*/
export class ManageCustomToolClientTool extends BaseClientTool {
static readonly id = 'manage_custom_tool'
private currentArgs?: ManageCustomToolArgs
constructor(toolCallId: string) {
super(toolCallId, ManageCustomToolClientTool.id, ManageCustomToolClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
text: 'Managing custom tool',
icon: Loader2,
},
[ClientToolCallState.pending]: { text: 'Manage custom tool?', icon: Plus },
[ClientToolCallState.executing]: { text: 'Managing custom tool', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Managed custom tool', icon: Check },
[ClientToolCallState.error]: { text: 'Failed to manage custom tool', icon: X },
[ClientToolCallState.aborted]: {
text: 'Aborted managing custom tool',
icon: XCircle,
},
[ClientToolCallState.rejected]: {
text: 'Skipped managing custom tool',
icon: XCircle,
},
},
interrupt: {
accept: { text: 'Allow', icon: Check },
reject: { text: 'Skip', icon: XCircle },
},
getDynamicText: (params, state) => {
const operation = params?.operation as 'add' | 'edit' | 'delete' | 'list' | undefined
if (!operation) return undefined
let toolName = params?.schema?.function?.name
if (!toolName && params?.toolId) {
try {
const tool = getCustomTool(params.toolId)
toolName = tool?.schema?.function?.name
} catch {
// Ignore errors accessing cache
}
}
const getActionText = (verb: 'present' | 'past' | 'gerund') => {
switch (operation) {
case 'add':
return verb === 'present' ? 'Create' : verb === 'past' ? 'Created' : 'Creating'
case 'edit':
return verb === 'present' ? 'Edit' : verb === 'past' ? 'Edited' : 'Editing'
case 'delete':
return verb === 'present' ? 'Delete' : verb === 'past' ? 'Deleted' : 'Deleting'
case 'list':
return verb === 'present' ? 'List' : verb === 'past' ? 'Listed' : 'Listing'
default:
return verb === 'present' ? 'Manage' : verb === 'past' ? 'Managed' : 'Managing'
}
}
// For add: only show tool name in past tense (success)
// For edit/delete: always show tool name
// For list: never show individual tool name, use plural
const shouldShowToolName = (currentState: ClientToolCallState) => {
if (operation === 'list') return false
if (operation === 'add') {
return currentState === ClientToolCallState.success
}
return true // edit and delete always show tool name
}
const nameText =
operation === 'list'
? ' custom tools'
: shouldShowToolName(state) && toolName
? ` ${toolName}`
: ' custom tool'
switch (state) {
case ClientToolCallState.success:
return `${getActionText('past')}${nameText}`
case ClientToolCallState.executing:
return `${getActionText('gerund')}${nameText}`
case ClientToolCallState.generating:
return `${getActionText('gerund')}${nameText}`
case ClientToolCallState.pending:
return `${getActionText('present')}${nameText}?`
case ClientToolCallState.error:
return `Failed to ${getActionText('present')?.toLowerCase()}${nameText}`
case ClientToolCallState.aborted:
return `Aborted ${getActionText('gerund')?.toLowerCase()}${nameText}`
case ClientToolCallState.rejected:
return `Skipped ${getActionText('gerund')?.toLowerCase()}${nameText}`
}
return undefined
},
}
/**
* Gets the tool call args from the copilot store (needed before execute() is called)
*/
private getArgsFromStore(): ManageCustomToolArgs | undefined {
try {
const { toolCallsById } = useCopilotStore.getState()
const toolCall = toolCallsById[this.toolCallId]
return (toolCall as any)?.params as ManageCustomToolArgs | undefined
} catch {
return undefined
}
}
/**
* Override getInterruptDisplays to only show confirmation for edit and delete operations.
* Add operations execute directly without confirmation.
*/
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
const args = this.currentArgs || this.getArgsFromStore()
const operation = args?.operation
if (operation === 'edit' || operation === 'delete') {
return this.metadata.interrupt
}
return undefined
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: ManageCustomToolArgs): Promise<void> {
const logger = createLogger('ManageCustomToolClientTool')
try {
this.setState(ClientToolCallState.executing)
await this.executeOperation(args, logger)
} catch (e: any) {
logger.error('execute failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to manage custom tool', {
success: false,
error: e?.message || 'Failed to manage custom tool',
})
}
}
async execute(args?: ManageCustomToolArgs): Promise<void> {
this.currentArgs = args
if (args?.operation === 'add' || args?.operation === 'list') {
await this.handleAccept(args)
}
}
/**
* Executes the custom tool operation (add, edit, delete, or list)
*/
private async executeOperation(
args: ManageCustomToolArgs | undefined,
logger: ReturnType<typeof createLogger>
): Promise<void> {
if (!args?.operation) {
throw new Error('Operation is required')
}
await checkCustomToolsPermission()
const { operation, toolId, schema, code } = args
const { hydration } = useWorkflowRegistry.getState()
const workspaceId = hydration.workspaceId
if (!workspaceId) {
throw new Error('No active workspace found')
}
logger.info(`Executing custom tool operation: ${operation}`, {
operation,
toolId,
functionName: schema?.function?.name,
workspaceId,
})
switch (operation) {
case 'add':
await this.addCustomTool({ schema, code, workspaceId }, logger)
break
case 'edit':
await this.editCustomTool({ toolId, schema, code, workspaceId }, logger)
break
case 'delete':
await this.deleteCustomTool({ toolId, workspaceId }, logger)
break
case 'list':
await this.markToolComplete(200, 'Listed custom tools')
break
default:
throw new Error(`Unknown operation: ${operation}`)
}
}
/**
* Creates a new custom tool
*/
private async addCustomTool(
params: {
schema?: CustomToolSchema
code?: string
workspaceId: string
},
logger: ReturnType<typeof createLogger>
): Promise<void> {
const { schema, code, workspaceId } = params
if (!schema) {
throw new Error('Schema is required for adding a custom tool')
}
if (!code) {
throw new Error('Code is required for adding a custom tool')
}
const functionName = schema.function.name
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tools: [{ title: functionName, schema, code }],
workspaceId,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to create custom tool')
}
if (!data.data || !Array.isArray(data.data) || data.data.length === 0) {
throw new Error('Invalid API response: missing tool data')
}
const createdTool = data.data[0]
logger.info(`Created custom tool: ${functionName}`, { toolId: createdTool.id })
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, `Created custom tool "${functionName}"`, {
success: true,
operation: 'add',
toolId: createdTool.id,
functionName,
})
}
/**
* Updates an existing custom tool
*/
private async editCustomTool(
params: {
toolId?: string
schema?: CustomToolSchema
code?: string
workspaceId: string
},
logger: ReturnType<typeof createLogger>
): Promise<void> {
const { toolId, schema, code, workspaceId } = params
if (!toolId) {
throw new Error('Tool ID is required for editing a custom tool')
}
if (!schema && !code) {
throw new Error('At least one of schema or code must be provided for editing')
}
const existingResponse = await fetch(`${API_ENDPOINT}?workspaceId=${workspaceId}`)
const existingData = await existingResponse.json()
if (!existingResponse.ok) {
throw new Error(existingData.error || 'Failed to fetch existing tools')
}
const existingTool = existingData.data?.find((t: any) => t.id === toolId)
if (!existingTool) {
throw new Error(`Tool with ID ${toolId} not found`)
}
const mergedSchema = schema ?? existingTool.schema
const updatedTool = {
id: toolId,
title: mergedSchema.function.name,
schema: mergedSchema,
code: code ?? existingTool.code,
}
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tools: [updatedTool],
workspaceId,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to update custom tool')
}
const functionName = updatedTool.schema.function.name
logger.info(`Updated custom tool: ${functionName}`, { toolId })
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, `Updated custom tool "${functionName}"`, {
success: true,
operation: 'edit',
toolId,
functionName,
})
}
/**
* Deletes a custom tool
*/
private async deleteCustomTool(
params: {
toolId?: string
workspaceId: string
},
logger: ReturnType<typeof createLogger>
): Promise<void> {
const { toolId, workspaceId } = params
if (!toolId) {
throw new Error('Tool ID is required for deleting a custom tool')
}
const url = `${API_ENDPOINT}?id=${toolId}&workspaceId=${workspaceId}`
const response = await fetch(url, {
method: 'DELETE',
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to delete custom tool')
}
logger.info(`Deleted custom tool: ${toolId}`)
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, `Deleted custom tool`, {
success: true,
operation: 'delete',
toolId,
})
}
}

View File

@@ -1,360 +0,0 @@
import { createLogger } from '@sim/logger'
import { Check, Loader2, Server, X, XCircle } from 'lucide-react'
import { client } from '@/lib/auth/auth-client'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { useCopilotStore } from '@/stores/panel/copilot/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface McpServerConfig {
name: string
transport: 'streamable-http'
url?: string
headers?: Record<string, string>
timeout?: number
enabled?: boolean
}
interface ManageMcpToolArgs {
operation: 'add' | 'edit' | 'delete'
serverId?: string
config?: McpServerConfig
}
const API_ENDPOINT = '/api/mcp/servers'
async function checkMcpToolsPermission(): Promise<void> {
const activeOrgResponse = await client.organization.getFullOrganization()
const organizationId = activeOrgResponse.data?.id
if (!organizationId) return
const response = await fetch(`/api/permission-groups/user?organizationId=${organizationId}`)
if (!response.ok) return
const data = await response.json()
if (data?.config?.disableMcpTools) {
throw new Error('MCP tools are not allowed based on your permission group settings')
}
}
/**
* Client tool for creating, editing, and deleting MCP tool servers via the copilot.
*/
export class ManageMcpToolClientTool extends BaseClientTool {
static readonly id = 'manage_mcp_tool'
private currentArgs?: ManageMcpToolArgs
constructor(toolCallId: string) {
super(toolCallId, ManageMcpToolClientTool.id, ManageMcpToolClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
text: 'Managing MCP tool',
icon: Loader2,
},
[ClientToolCallState.pending]: { text: 'Manage MCP tool?', icon: Server },
[ClientToolCallState.executing]: { text: 'Managing MCP tool', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Managed MCP tool', icon: Check },
[ClientToolCallState.error]: { text: 'Failed to manage MCP tool', icon: X },
[ClientToolCallState.aborted]: {
text: 'Aborted managing MCP tool',
icon: XCircle,
},
[ClientToolCallState.rejected]: {
text: 'Skipped managing MCP tool',
icon: XCircle,
},
},
interrupt: {
accept: { text: 'Allow', icon: Check },
reject: { text: 'Skip', icon: XCircle },
},
getDynamicText: (params, state) => {
const operation = params?.operation as 'add' | 'edit' | 'delete' | undefined
if (!operation) return undefined
const serverName = params?.config?.name || params?.serverName
const getActionText = (verb: 'present' | 'past' | 'gerund') => {
switch (operation) {
case 'add':
return verb === 'present' ? 'Add' : verb === 'past' ? 'Added' : 'Adding'
case 'edit':
return verb === 'present' ? 'Edit' : verb === 'past' ? 'Edited' : 'Editing'
case 'delete':
return verb === 'present' ? 'Delete' : verb === 'past' ? 'Deleted' : 'Deleting'
}
}
const shouldShowServerName = (currentState: ClientToolCallState) => {
if (operation === 'add') {
return currentState === ClientToolCallState.success
}
return true
}
const nameText = shouldShowServerName(state) && serverName ? ` ${serverName}` : ' MCP tool'
switch (state) {
case ClientToolCallState.success:
return `${getActionText('past')}${nameText}`
case ClientToolCallState.executing:
return `${getActionText('gerund')}${nameText}`
case ClientToolCallState.generating:
return `${getActionText('gerund')}${nameText}`
case ClientToolCallState.pending:
return `${getActionText('present')}${nameText}?`
case ClientToolCallState.error:
return `Failed to ${getActionText('present')?.toLowerCase()}${nameText}`
case ClientToolCallState.aborted:
return `Aborted ${getActionText('gerund')?.toLowerCase()}${nameText}`
case ClientToolCallState.rejected:
return `Skipped ${getActionText('gerund')?.toLowerCase()}${nameText}`
}
return undefined
},
}
/**
* Gets the tool call args from the copilot store (needed before execute() is called)
*/
private getArgsFromStore(): ManageMcpToolArgs | undefined {
try {
const { toolCallsById } = useCopilotStore.getState()
const toolCall = toolCallsById[this.toolCallId]
return (toolCall as any)?.params as ManageMcpToolArgs | undefined
} catch {
return undefined
}
}
/**
* Override getInterruptDisplays to only show confirmation for edit and delete operations.
* Add operations execute directly without confirmation.
*/
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
const args = this.currentArgs || this.getArgsFromStore()
const operation = args?.operation
if (operation === 'edit' || operation === 'delete') {
return this.metadata.interrupt
}
return undefined
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: ManageMcpToolArgs): Promise<void> {
const logger = createLogger('ManageMcpToolClientTool')
try {
this.setState(ClientToolCallState.executing)
await this.executeOperation(args, logger)
} catch (e: any) {
logger.error('execute failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to manage MCP tool', {
success: false,
error: e?.message || 'Failed to manage MCP tool',
})
}
}
async execute(args?: ManageMcpToolArgs): Promise<void> {
this.currentArgs = args
if (args?.operation === 'add') {
await this.handleAccept(args)
}
}
/**
* Executes the MCP tool operation (add, edit, or delete)
*/
private async executeOperation(
args: ManageMcpToolArgs | undefined,
logger: ReturnType<typeof createLogger>
): Promise<void> {
if (!args?.operation) {
throw new Error('Operation is required')
}
await checkMcpToolsPermission()
const { operation, serverId, config } = args
const { hydration } = useWorkflowRegistry.getState()
const workspaceId = hydration.workspaceId
if (!workspaceId) {
throw new Error('No active workspace found')
}
logger.info(`Executing MCP tool operation: ${operation}`, {
operation,
serverId,
serverName: config?.name,
workspaceId,
})
switch (operation) {
case 'add':
await this.addMcpServer({ config, workspaceId }, logger)
break
case 'edit':
await this.editMcpServer({ serverId, config, workspaceId }, logger)
break
case 'delete':
await this.deleteMcpServer({ serverId, workspaceId }, logger)
break
default:
throw new Error(`Unknown operation: ${operation}`)
}
}
/**
* Creates a new MCP server
*/
private async addMcpServer(
params: {
config?: McpServerConfig
workspaceId: string
},
logger: ReturnType<typeof createLogger>
): Promise<void> {
const { config, workspaceId } = params
if (!config) {
throw new Error('Config is required for adding an MCP tool')
}
if (!config.name) {
throw new Error('Server name is required')
}
if (!config.url) {
throw new Error('Server URL is required for streamable-http transport')
}
const serverData = {
...config,
workspaceId,
transport: config.transport || 'streamable-http',
timeout: config.timeout || 30000,
enabled: config.enabled !== false,
}
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(serverData),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to create MCP tool')
}
const serverId = data.data?.serverId
logger.info(`Created MCP tool: ${config.name}`, { serverId })
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, `Created MCP tool "${config.name}"`, {
success: true,
operation: 'add',
serverId,
serverName: config.name,
})
}
/**
* Updates an existing MCP server
*/
private async editMcpServer(
params: {
serverId?: string
config?: McpServerConfig
workspaceId: string
},
logger: ReturnType<typeof createLogger>
): Promise<void> {
const { serverId, config, workspaceId } = params
if (!serverId) {
throw new Error('Server ID is required for editing an MCP tool')
}
if (!config) {
throw new Error('Config is required for editing an MCP tool')
}
const updateData = {
...config,
workspaceId,
}
const response = await fetch(`${API_ENDPOINT}/${serverId}?workspaceId=${workspaceId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to update MCP tool')
}
const serverName = config.name || data.data?.server?.name || serverId
logger.info(`Updated MCP tool: ${serverName}`, { serverId })
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, `Updated MCP tool "${serverName}"`, {
success: true,
operation: 'edit',
serverId,
serverName,
})
}
/**
* Deletes an MCP server
*/
private async deleteMcpServer(
params: {
serverId?: string
workspaceId: string
},
logger: ReturnType<typeof createLogger>
): Promise<void> {
const { serverId, workspaceId } = params
if (!serverId) {
throw new Error('Server ID is required for deleting an MCP tool')
}
const url = `${API_ENDPOINT}?serverId=${serverId}&workspaceId=${workspaceId}`
const response = await fetch(url, {
method: 'DELETE',
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to delete MCP tool')
}
logger.info(`Deleted MCP tool: ${serverId}`)
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, `Deleted MCP tool`, {
success: true,
operation: 'delete',
serverId,
})
}
}

View File

@@ -1,71 +0,0 @@
import { createLogger } from '@sim/logger'
import { Loader2, Rocket, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export class RedeployClientTool extends BaseClientTool {
static readonly id = 'redeploy'
private hasExecuted = false
constructor(toolCallId: string) {
super(toolCallId, RedeployClientTool.id, RedeployClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Redeploying workflow', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Redeploy workflow', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Redeploying workflow', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Redeployed workflow', icon: Rocket },
[ClientToolCallState.error]: { text: 'Failed to redeploy workflow', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted redeploy', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped redeploy', icon: XCircle },
},
interrupt: undefined,
}
async execute(): Promise<void> {
const logger = createLogger('RedeployClientTool')
try {
if (this.hasExecuted) {
logger.info('execute skipped (already executed)', { toolCallId: this.toolCallId })
return
}
this.hasExecuted = true
this.setState(ClientToolCallState.executing)
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (!activeWorkflowId) {
throw new Error('No workflow ID provided')
}
const res = await fetch(`/api/workflows/${activeWorkflowId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deployChatEnabled: false }),
})
const json = await res.json().catch(() => ({}))
if (!res.ok) {
const errorText = json?.error || `Server error (${res.status})`
throw new Error(errorText)
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'Workflow redeployed', {
workflowId: activeWorkflowId,
deployedAt: json?.deployedAt || null,
schedule: json?.schedule,
})
} catch (error: any) {
logger.error('Redeploy failed', { message: error?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, error?.message || 'Failed to redeploy workflow')
}
}
}

View File

@@ -1,231 +0,0 @@
import { createLogger } from '@sim/logger'
import { Loader2, MinusCircle, Play, XCircle } from 'lucide-react'
import { v4 as uuidv4 } from 'uuid'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
WORKFLOW_EXECUTION_TIMEOUT_MS,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { executeWorkflowWithFullLogging } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { useExecutionStore } from '@/stores/execution'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface RunWorkflowArgs {
workflowId?: string
description?: string
workflow_input?: Record<string, any>
}
export class RunWorkflowClientTool extends BaseClientTool {
static readonly id = 'run_workflow'
constructor(toolCallId: string) {
super(toolCallId, RunWorkflowClientTool.id, RunWorkflowClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Preparing to run your workflow', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Run this workflow?', icon: Play },
[ClientToolCallState.executing]: { text: 'Running your workflow', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Executed workflow', icon: Play },
[ClientToolCallState.error]: { text: 'Errored running workflow', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped workflow execution', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Aborted workflow execution', icon: MinusCircle },
[ClientToolCallState.background]: { text: 'Running in background', icon: Play },
},
interrupt: {
accept: { text: 'Run', icon: Play },
reject: { text: 'Skip', icon: MinusCircle },
},
uiConfig: {
isSpecial: true,
interrupt: {
accept: { text: 'Run', icon: Play },
reject: { text: 'Skip', icon: MinusCircle },
showAllowOnce: true,
showAllowAlways: true,
},
secondaryAction: {
text: 'Move to Background',
title: 'Move to Background',
variant: 'tertiary',
showInStates: [ClientToolCallState.executing],
completionMessage:
'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',
targetState: ClientToolCallState.background,
},
paramsTable: {
columns: [
{ key: 'input', label: 'Input', width: '36%' },
{ key: 'value', label: 'Value', width: '64%', editable: true, mono: true },
],
extractRows: (params) => {
let inputs = params.input || params.inputs || params.workflow_input
if (typeof inputs === 'string') {
try {
inputs = JSON.parse(inputs)
} catch {
inputs = {}
}
}
if (params.workflow_input && typeof params.workflow_input === 'object') {
inputs = params.workflow_input
}
if (!inputs || typeof inputs !== 'object') {
const { workflowId, workflow_input, ...rest } = params
inputs = rest
}
const safeInputs = inputs && typeof inputs === 'object' ? inputs : {}
return Object.entries(safeInputs).map(([key, value]) => [key, key, String(value)])
},
},
},
getDynamicText: (params, state) => {
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
if (workflowId) {
const workflowName = useWorkflowRegistry.getState().workflows[workflowId]?.name
if (workflowName) {
switch (state) {
case ClientToolCallState.success:
return `Ran ${workflowName}`
case ClientToolCallState.executing:
return `Running ${workflowName}`
case ClientToolCallState.generating:
return `Preparing to run ${workflowName}`
case ClientToolCallState.pending:
return `Run ${workflowName}?`
case ClientToolCallState.error:
return `Failed to run ${workflowName}`
case ClientToolCallState.rejected:
return `Skipped running ${workflowName}`
case ClientToolCallState.aborted:
return `Aborted running ${workflowName}`
case ClientToolCallState.background:
return `Running ${workflowName} in background`
}
}
}
return undefined
},
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: RunWorkflowArgs): Promise<void> {
const logger = createLogger('RunWorkflowClientTool')
// Use longer timeout for workflow execution (10 minutes)
await this.executeWithTimeout(async () => {
const params = args || {}
logger.debug('handleAccept() called', {
toolCallId: this.toolCallId,
state: this.getState(),
hasArgs: !!args,
argKeys: args ? Object.keys(args) : [],
})
// prevent concurrent execution
const { isExecuting, setIsExecuting } = useExecutionStore.getState()
if (isExecuting) {
logger.debug('Execution prevented: already executing')
this.setState(ClientToolCallState.error)
await this.markToolComplete(
409,
'The workflow is already in the middle of an execution. Try again later'
)
return
}
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (!activeWorkflowId) {
logger.debug('Execution prevented: no active workflow')
this.setState(ClientToolCallState.error)
await this.markToolComplete(400, 'No active workflow found')
return
}
logger.debug('Using active workflow', { activeWorkflowId })
const workflowInput = params.workflow_input || undefined
if (workflowInput) {
logger.debug('Workflow input provided', {
inputFields: Object.keys(workflowInput),
inputPreview: JSON.stringify(workflowInput).slice(0, 120),
})
}
setIsExecuting(true)
logger.debug('Set isExecuting(true) and switching state to executing')
this.setState(ClientToolCallState.executing)
const executionId = uuidv4()
const executionStartTime = new Date().toISOString()
logger.debug('Starting workflow execution', {
executionStartTime,
executionId,
toolCallId: this.toolCallId,
})
try {
const result = await executeWorkflowWithFullLogging({
workflowInput,
executionId,
})
// Determine success for both non-streaming and streaming executions
let succeeded = true
let errorMessage: string | undefined
try {
if (result && typeof result === 'object' && 'success' in (result as any)) {
succeeded = Boolean((result as any).success)
if (!succeeded) {
errorMessage = (result as any)?.error || (result as any)?.output?.error
}
} else if (
result &&
typeof result === 'object' &&
'execution' in (result as any) &&
(result as any).execution &&
typeof (result as any).execution === 'object'
) {
succeeded = Boolean((result as any).execution.success)
if (!succeeded) {
errorMessage =
(result as any).execution?.error || (result as any).execution?.output?.error
}
}
} catch {}
if (succeeded) {
logger.debug('Workflow execution finished with success')
this.setState(ClientToolCallState.success)
await this.markToolComplete(
200,
`Workflow execution completed. Started at: ${executionStartTime}`
)
} else {
const msg = errorMessage || 'Workflow execution failed'
logger.error('Workflow execution finished with failure', { message: msg })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, msg)
}
} finally {
// Always clean up execution state
setIsExecuting(false)
}
}, WORKFLOW_EXECUTION_TIMEOUT_MS)
}
async execute(args?: RunWorkflowArgs): Promise<void> {
// For compatibility if execute() is explicitly invoked, route to handleAccept
await this.handleAccept(args)
}
}
// Register UI config at module load
registerToolUIConfig(RunWorkflowClientTool.id, RunWorkflowClientTool.metadata.uiConfig!)

View File

@@ -1,278 +0,0 @@
import { createLogger } from '@sim/logger'
import { Loader2, Settings2, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface OperationItem {
operation: 'add' | 'edit' | 'delete'
name: string
type?: 'plain' | 'number' | 'boolean' | 'array' | 'object'
value?: string
}
interface SetGlobalVarsArgs {
operations: OperationItem[]
workflowId?: string
}
export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool {
static readonly id = 'set_global_workflow_variables'
constructor(toolCallId: string) {
super(
toolCallId,
SetGlobalWorkflowVariablesClientTool.id,
SetGlobalWorkflowVariablesClientTool.metadata
)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
text: 'Preparing to set workflow variables',
icon: Loader2,
},
[ClientToolCallState.pending]: { text: 'Set workflow variables?', icon: Settings2 },
[ClientToolCallState.executing]: { text: 'Setting workflow variables', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Updated workflow variables', icon: Settings2 },
[ClientToolCallState.error]: { text: 'Failed to set workflow variables', icon: X },
[ClientToolCallState.aborted]: { text: 'Aborted setting variables', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped setting variables', icon: XCircle },
},
interrupt: {
accept: { text: 'Apply', icon: Settings2 },
reject: { text: 'Skip', icon: XCircle },
},
uiConfig: {
interrupt: {
accept: { text: 'Apply', icon: Settings2 },
reject: { text: 'Skip', icon: XCircle },
showAllowOnce: true,
showAllowAlways: true,
},
paramsTable: {
columns: [
{ key: 'name', label: 'Name', width: '40%', editable: true, mono: true },
{ key: 'value', label: 'Value', width: '60%', editable: true, mono: true },
],
extractRows: (params) => {
const operations = params.operations || []
return operations.map((op: any, idx: number) => [
String(idx),
op.name || '',
String(op.value ?? ''),
])
},
},
},
getDynamicText: (params, state) => {
if (params?.operations && Array.isArray(params.operations)) {
const varNames = params.operations
.slice(0, 2)
.map((op: any) => op.name)
.filter(Boolean)
if (varNames.length > 0) {
const varList = varNames.join(', ')
const more = params.operations.length > 2 ? '...' : ''
const displayText = `${varList}${more}`
switch (state) {
case ClientToolCallState.success:
return `Set ${displayText}`
case ClientToolCallState.executing:
return `Setting ${displayText}`
case ClientToolCallState.generating:
return `Preparing to set ${displayText}`
case ClientToolCallState.pending:
return `Set ${displayText}?`
case ClientToolCallState.error:
return `Failed to set ${displayText}`
case ClientToolCallState.aborted:
return `Aborted setting ${displayText}`
case ClientToolCallState.rejected:
return `Skipped setting ${displayText}`
}
}
}
return undefined
},
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: SetGlobalVarsArgs): Promise<void> {
const logger = createLogger('SetGlobalWorkflowVariablesClientTool')
try {
this.setState(ClientToolCallState.executing)
const payload: SetGlobalVarsArgs = { ...(args || { operations: [] }) }
if (!payload.workflowId) {
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (activeWorkflowId) payload.workflowId = activeWorkflowId
}
if (!payload.workflowId) {
throw new Error('No active workflow found')
}
// Fetch current variables so we can construct full array payload
const getRes = await fetch(`/api/workflows/${payload.workflowId}/variables`, {
method: 'GET',
})
if (!getRes.ok) {
const txt = await getRes.text().catch(() => '')
throw new Error(txt || 'Failed to load current variables')
}
const currentJson = await getRes.json()
const currentVarsRecord = (currentJson?.data as Record<string, any>) || {}
// Helper to convert string -> typed value
function coerceValue(
value: string | undefined,
type?: 'plain' | 'number' | 'boolean' | 'array' | 'object'
) {
if (value === undefined) return value
const t = type || 'plain'
try {
if (t === 'number') {
const n = Number(value)
if (Number.isNaN(n)) return value
return n
}
if (t === 'boolean') {
const v = String(value).trim().toLowerCase()
if (v === 'true') return true
if (v === 'false') return false
return value
}
if (t === 'array' || t === 'object') {
const parsed = JSON.parse(value)
if (t === 'array' && Array.isArray(parsed)) return parsed
if (t === 'object' && parsed && typeof parsed === 'object' && !Array.isArray(parsed))
return parsed
return value
}
} catch {}
return value
}
// Build mutable map by variable name
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
})
// Apply operations in order
for (const op of payload.operations || []) {
const key = String(op.name)
const nextType = (op.type as any) || byName[key]?.type || 'plain'
if (op.operation === 'delete') {
delete byName[key]
continue
}
const typedValue = coerceValue(op.value, nextType)
if (op.operation === 'add') {
byName[key] = {
id: crypto.randomUUID(),
workflowId: payload.workflowId,
name: key,
type: nextType,
value: typedValue,
}
continue
}
if (op.operation === 'edit') {
if (!byName[key]) {
// If editing a non-existent variable, create it
byName[key] = {
id: crypto.randomUUID(),
workflowId: payload.workflowId,
name: key,
type: nextType,
value: typedValue,
}
} else {
byName[key] = {
...byName[key],
type: nextType,
...(op.value !== undefined ? { value: typedValue } : {}),
}
}
}
}
// Convert byName (keyed by name) to record keyed by ID for the API
const variablesRecord: Record<string, any> = {}
for (const v of Object.values(byName)) {
variablesRecord[v.id] = v
}
// POST full variables record to persist
const res = await fetch(`/api/workflows/${payload.workflowId}/variables`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ variables: variablesRecord }),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(txt || `Failed to update variables (${res.status})`)
}
try {
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (activeWorkflowId) {
// Fetch the updated variables from the API
const refreshRes = await fetch(`/api/workflows/${activeWorkflowId}/variables`, {
method: 'GET',
})
if (refreshRes.ok) {
const refreshJson = await refreshRes.json()
const updatedVarsRecord = (refreshJson?.data as Record<string, any>) || {}
// Update the variables store with the fresh data
useVariablesStore.setState((state) => {
// Remove old variables for this workflow
const withoutWorkflow = Object.fromEntries(
Object.entries(state.variables).filter(([, v]) => v.workflowId !== activeWorkflowId)
)
// Add the updated variables
return {
variables: { ...withoutWorkflow, ...updatedVarsRecord },
}
})
logger.info('Refreshed variables in store', { workflowId: activeWorkflowId })
}
}
} catch (refreshError) {
logger.warn('Failed to refresh variables in store', { error: refreshError })
}
await this.markToolComplete(200, 'Workflow variables updated', { variables: byName })
this.setState(ClientToolCallState.success)
} catch (e: any) {
const message = e instanceof Error ? e.message : String(e)
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, message || 'Failed to set workflow variables')
}
}
async execute(args?: SetGlobalVarsArgs): Promise<void> {
await this.handleAccept(args)
}
}
// Register UI config at module load
registerToolUIConfig(
SetGlobalWorkflowVariablesClientTool.id,
SetGlobalWorkflowVariablesClientTool.metadata.uiConfig!
)

View File

@@ -10,76 +10,11 @@ import {
shouldSkipToolCallEvent,
shouldSkipToolResultEvent,
} from '@/lib/copilot/orchestrator/sse-utils'
import type {
BaseClientToolMetadata,
ClientToolDisplay,
} from '@/lib/copilot/tools/client/base-tool'
import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool'
import { GetBlockConfigClientTool } from '@/lib/copilot/tools/client/blocks/get-block-config'
import { GetBlockOptionsClientTool } from '@/lib/copilot/tools/client/blocks/get-block-options'
import { GetBlocksAndToolsClientTool } from '@/lib/copilot/tools/client/blocks/get-blocks-and-tools'
import { GetBlocksMetadataClientTool } from '@/lib/copilot/tools/client/blocks/get-blocks-metadata'
import { GetTriggerBlocksClientTool } from '@/lib/copilot/tools/client/blocks/get-trigger-blocks'
import { GetExamplesRagClientTool } from '@/lib/copilot/tools/client/examples/get-examples-rag'
import { GetOperationsExamplesClientTool } from '@/lib/copilot/tools/client/examples/get-operations-examples'
import { GetTriggerExamplesClientTool } from '@/lib/copilot/tools/client/examples/get-trigger-examples'
import { SummarizeClientTool } from '@/lib/copilot/tools/client/examples/summarize'
import { KnowledgeBaseClientTool } from '@/lib/copilot/tools/client/knowledge/knowledge-base'
import {
getClientTool,
registerClientTool,
registerToolStateSync,
} from '@/lib/copilot/tools/client/manager'
import { NavigateUIClientTool } from '@/lib/copilot/tools/client/navigation/navigate-ui'
import { AuthClientTool } from '@/lib/copilot/tools/client/other/auth'
import { CheckoffTodoClientTool } from '@/lib/copilot/tools/client/other/checkoff-todo'
import { CrawlWebsiteClientTool } from '@/lib/copilot/tools/client/other/crawl-website'
import { CustomToolClientTool } from '@/lib/copilot/tools/client/other/custom-tool'
import { DebugClientTool } from '@/lib/copilot/tools/client/other/debug'
import { DeployClientTool } from '@/lib/copilot/tools/client/other/deploy'
import { EditClientTool } from '@/lib/copilot/tools/client/other/edit'
import { EvaluateClientTool } from '@/lib/copilot/tools/client/other/evaluate'
import { GetPageContentsClientTool } from '@/lib/copilot/tools/client/other/get-page-contents'
import { InfoClientTool } from '@/lib/copilot/tools/client/other/info'
import { KnowledgeClientTool } from '@/lib/copilot/tools/client/other/knowledge'
import { MakeApiRequestClientTool } from '@/lib/copilot/tools/client/other/make-api-request'
import { MarkTodoInProgressClientTool } from '@/lib/copilot/tools/client/other/mark-todo-in-progress'
import { OAuthRequestAccessClientTool } from '@/lib/copilot/tools/client/other/oauth-request-access'
import { PlanClientTool } from '@/lib/copilot/tools/client/other/plan'
import { RememberDebugClientTool } from '@/lib/copilot/tools/client/other/remember-debug'
import { ResearchClientTool } from '@/lib/copilot/tools/client/other/research'
import { ScrapePageClientTool } from '@/lib/copilot/tools/client/other/scrape-page'
import { SearchDocumentationClientTool } from '@/lib/copilot/tools/client/other/search-documentation'
import { SearchErrorsClientTool } from '@/lib/copilot/tools/client/other/search-errors'
import { SearchLibraryDocsClientTool } from '@/lib/copilot/tools/client/other/search-library-docs'
import { SearchOnlineClientTool } from '@/lib/copilot/tools/client/other/search-online'
import { SearchPatternsClientTool } from '@/lib/copilot/tools/client/other/search-patterns'
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 { 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'
import { CreateWorkspaceMcpServerClientTool } from '@/lib/copilot/tools/client/workflow/create-workspace-mcp-server'
import { DeployApiClientTool } from '@/lib/copilot/tools/client/workflow/deploy-api'
import { DeployChatClientTool } from '@/lib/copilot/tools/client/workflow/deploy-chat'
import { DeployMcpClientTool } from '@/lib/copilot/tools/client/workflow/deploy-mcp'
import { EditWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/edit-workflow'
import { GetBlockOutputsClientTool } from '@/lib/copilot/tools/client/workflow/get-block-outputs'
import { GetBlockUpstreamReferencesClientTool } from '@/lib/copilot/tools/client/workflow/get-block-upstream-references'
import { GetUserWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/get-user-workflow'
import { GetWorkflowConsoleClientTool } from '@/lib/copilot/tools/client/workflow/get-workflow-console'
import { GetWorkflowDataClientTool } from '@/lib/copilot/tools/client/workflow/get-workflow-data'
import { GetWorkflowFromNameClientTool } from '@/lib/copilot/tools/client/workflow/get-workflow-from-name'
import { ListUserWorkflowsClientTool } from '@/lib/copilot/tools/client/workflow/list-user-workflows'
import { ListWorkspaceMcpServersClientTool } from '@/lib/copilot/tools/client/workflow/list-workspace-mcp-servers'
import { ManageCustomToolClientTool } from '@/lib/copilot/tools/client/workflow/manage-custom-tool'
import { ManageMcpToolClientTool } from '@/lib/copilot/tools/client/workflow/manage-mcp-tool'
import { RedeployClientTool } from '@/lib/copilot/tools/client/workflow/redeploy'
import { RunWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/run-workflow'
import { SetGlobalWorkflowVariablesClientTool } from '@/lib/copilot/tools/client/workflow/set-global-workflow-variables'
ClientToolCallState,
type ClientToolDisplay,
TOOL_DISPLAY_REGISTRY,
} from '@/lib/copilot/tools/client/tool-display-registry'
import { getQueryClient } from '@/app/_shell/providers/query-provider'
import { subscriptionKeys } from '@/hooks/queries/subscription'
import type {
@@ -175,144 +110,6 @@ try {
}
} catch {}
// Known class-based client tools: map tool name -> instantiator
const CLIENT_TOOL_INSTANTIATORS: Record<string, (id: string) => any> = {
plan: (id) => new PlanClientTool(id),
edit: (id) => new EditClientTool(id),
debug: (id) => new DebugClientTool(id),
test: (id) => new TestClientTool(id),
deploy: (id) => new DeployClientTool(id),
evaluate: (id) => new EvaluateClientTool(id),
auth: (id) => new AuthClientTool(id),
research: (id) => new ResearchClientTool(id),
knowledge: (id) => new KnowledgeClientTool(id),
custom_tool: (id) => new CustomToolClientTool(id),
tour: (id) => new TourClientTool(id),
info: (id) => new InfoClientTool(id),
workflow: (id) => new WorkflowClientTool(id),
run_workflow: (id) => new RunWorkflowClientTool(id),
get_workflow_console: (id) => new GetWorkflowConsoleClientTool(id),
get_blocks_and_tools: (id) => new GetBlocksAndToolsClientTool(id),
get_blocks_metadata: (id) => new GetBlocksMetadataClientTool(id),
get_block_options: (id) => new GetBlockOptionsClientTool(id),
get_block_config: (id) => new GetBlockConfigClientTool(id),
get_trigger_blocks: (id) => new GetTriggerBlocksClientTool(id),
search_online: (id) => new SearchOnlineClientTool(id),
search_documentation: (id) => new SearchDocumentationClientTool(id),
search_library_docs: (id) => new SearchLibraryDocsClientTool(id),
search_patterns: (id) => new SearchPatternsClientTool(id),
search_errors: (id) => new SearchErrorsClientTool(id),
scrape_page: (id) => new ScrapePageClientTool(id),
get_page_contents: (id) => new GetPageContentsClientTool(id),
crawl_website: (id) => new CrawlWebsiteClientTool(id),
remember_debug: (id) => new RememberDebugClientTool(id),
set_environment_variables: (id) => new SetEnvironmentVariablesClientTool(id),
get_credentials: (id) => new GetCredentialsClientTool(id),
knowledge_base: (id) => new KnowledgeBaseClientTool(id),
make_api_request: (id) => new MakeApiRequestClientTool(id),
checkoff_todo: (id) => new CheckoffTodoClientTool(id),
mark_todo_in_progress: (id) => new MarkTodoInProgressClientTool(id),
oauth_request_access: (id) => new OAuthRequestAccessClientTool(id),
edit_workflow: (id) => new EditWorkflowClientTool(id),
get_user_workflow: (id) => new GetUserWorkflowClientTool(id),
list_user_workflows: (id) => new ListUserWorkflowsClientTool(id),
get_workflow_from_name: (id) => new GetWorkflowFromNameClientTool(id),
get_workflow_data: (id) => new GetWorkflowDataClientTool(id),
set_global_workflow_variables: (id) => new SetGlobalWorkflowVariablesClientTool(id),
get_trigger_examples: (id) => new GetTriggerExamplesClientTool(id),
get_examples_rag: (id) => new GetExamplesRagClientTool(id),
get_operations_examples: (id) => new GetOperationsExamplesClientTool(id),
summarize_conversation: (id) => new SummarizeClientTool(id),
deploy_api: (id) => new DeployApiClientTool(id),
deploy_chat: (id) => new DeployChatClientTool(id),
deploy_mcp: (id) => new DeployMcpClientTool(id),
redeploy: (id) => new RedeployClientTool(id),
list_workspace_mcp_servers: (id) => new ListWorkspaceMcpServersClientTool(id),
create_workspace_mcp_server: (id) => new CreateWorkspaceMcpServerClientTool(id),
check_deployment_status: (id) => new CheckDeploymentStatusClientTool(id),
navigate_ui: (id) => new NavigateUIClientTool(id),
manage_custom_tool: (id) => new ManageCustomToolClientTool(id),
manage_mcp_tool: (id) => new ManageMcpToolClientTool(id),
sleep: (id) => new SleepClientTool(id),
get_block_outputs: (id) => new GetBlockOutputsClientTool(id),
get_block_upstream_references: (id) => new GetBlockUpstreamReferencesClientTool(id),
}
// Read-only static metadata for class-based tools (no instances)
export const CLASS_TOOL_METADATA: Record<string, BaseClientToolMetadata | undefined> = {
plan: (PlanClientTool as any)?.metadata,
edit: (EditClientTool as any)?.metadata,
debug: (DebugClientTool as any)?.metadata,
test: (TestClientTool as any)?.metadata,
deploy: (DeployClientTool as any)?.metadata,
evaluate: (EvaluateClientTool as any)?.metadata,
auth: (AuthClientTool as any)?.metadata,
research: (ResearchClientTool as any)?.metadata,
knowledge: (KnowledgeClientTool as any)?.metadata,
custom_tool: (CustomToolClientTool as any)?.metadata,
tour: (TourClientTool as any)?.metadata,
info: (InfoClientTool as any)?.metadata,
workflow: (WorkflowClientTool as any)?.metadata,
run_workflow: (RunWorkflowClientTool as any)?.metadata,
get_workflow_console: (GetWorkflowConsoleClientTool as any)?.metadata,
get_blocks_and_tools: (GetBlocksAndToolsClientTool as any)?.metadata,
get_blocks_metadata: (GetBlocksMetadataClientTool as any)?.metadata,
get_block_options: (GetBlockOptionsClientTool as any)?.metadata,
get_block_config: (GetBlockConfigClientTool as any)?.metadata,
get_trigger_blocks: (GetTriggerBlocksClientTool as any)?.metadata,
search_online: (SearchOnlineClientTool as any)?.metadata,
search_documentation: (SearchDocumentationClientTool as any)?.metadata,
search_library_docs: (SearchLibraryDocsClientTool as any)?.metadata,
search_patterns: (SearchPatternsClientTool as any)?.metadata,
search_errors: (SearchErrorsClientTool as any)?.metadata,
scrape_page: (ScrapePageClientTool as any)?.metadata,
get_page_contents: (GetPageContentsClientTool as any)?.metadata,
crawl_website: (CrawlWebsiteClientTool as any)?.metadata,
remember_debug: (RememberDebugClientTool as any)?.metadata,
set_environment_variables: (SetEnvironmentVariablesClientTool as any)?.metadata,
get_credentials: (GetCredentialsClientTool as any)?.metadata,
knowledge_base: (KnowledgeBaseClientTool as any)?.metadata,
make_api_request: (MakeApiRequestClientTool as any)?.metadata,
checkoff_todo: (CheckoffTodoClientTool as any)?.metadata,
mark_todo_in_progress: (MarkTodoInProgressClientTool as any)?.metadata,
edit_workflow: (EditWorkflowClientTool as any)?.metadata,
get_user_workflow: (GetUserWorkflowClientTool as any)?.metadata,
list_user_workflows: (ListUserWorkflowsClientTool as any)?.metadata,
get_workflow_from_name: (GetWorkflowFromNameClientTool as any)?.metadata,
get_workflow_data: (GetWorkflowDataClientTool as any)?.metadata,
set_global_workflow_variables: (SetGlobalWorkflowVariablesClientTool as any)?.metadata,
get_trigger_examples: (GetTriggerExamplesClientTool as any)?.metadata,
get_examples_rag: (GetExamplesRagClientTool as any)?.metadata,
oauth_request_access: (OAuthRequestAccessClientTool as any)?.metadata,
get_operations_examples: (GetOperationsExamplesClientTool as any)?.metadata,
summarize_conversation: (SummarizeClientTool as any)?.metadata,
deploy_api: (DeployApiClientTool as any)?.metadata,
deploy_chat: (DeployChatClientTool as any)?.metadata,
deploy_mcp: (DeployMcpClientTool as any)?.metadata,
redeploy: (RedeployClientTool as any)?.metadata,
list_workspace_mcp_servers: (ListWorkspaceMcpServersClientTool as any)?.metadata,
create_workspace_mcp_server: (CreateWorkspaceMcpServerClientTool as any)?.metadata,
check_deployment_status: (CheckDeploymentStatusClientTool as any)?.metadata,
navigate_ui: (NavigateUIClientTool as any)?.metadata,
manage_custom_tool: (ManageCustomToolClientTool as any)?.metadata,
manage_mcp_tool: (ManageMcpToolClientTool as any)?.metadata,
sleep: (SleepClientTool as any)?.metadata,
get_block_outputs: (GetBlockOutputsClientTool as any)?.metadata,
get_block_upstream_references: (GetBlockUpstreamReferencesClientTool as any)?.metadata,
}
function ensureClientToolInstance(toolName: string | undefined, toolCallId: string | undefined) {
try {
if (!toolName || !toolCallId) return
if (getClientTool(toolCallId)) return
const make = CLIENT_TOOL_INSTANTIATORS[toolName]
if (make) {
const inst = make(toolCallId)
registerClientTool(toolCallId, inst)
}
} catch {}
}
// Constants
const TEXT_BLOCK_TYPE = 'text'
const THINKING_BLOCK_TYPE = 'thinking'
@@ -324,75 +121,54 @@ const CONTINUE_OPTIONS_TAG = '<options>{"1":"Continue"}</options>'
function resolveToolDisplay(
toolName: string | undefined,
state: ClientToolCallState,
toolCallId?: string,
_toolCallId?: string,
params?: Record<string, any>
): ClientToolDisplay | undefined {
try {
if (!toolName) return undefined
const def = getTool(toolName) as any
const toolMetadata = def?.metadata || CLASS_TOOL_METADATA[toolName]
const meta = toolMetadata?.displayNames || {}
if (!toolName) return undefined
const entry = TOOL_DISPLAY_REGISTRY[toolName]
if (!entry) return humanizedFallback(toolName, state)
// Exact state first
const ds = meta?.[state]
if (ds?.text || ds?.icon) {
// Check if tool has a dynamic text formatter
const getDynamicText = toolMetadata?.getDynamicText
if (getDynamicText && params) {
try {
const dynamicText = getDynamicText(params, state)
if (dynamicText) {
return { text: dynamicText, icon: ds.icon }
}
} catch (e) {
// Fall back to static text if formatter fails
}
}
return { text: ds.text, icon: ds.icon }
// Check dynamic text first
if (entry.uiConfig?.dynamicText && params) {
const dynamicText = entry.uiConfig.dynamicText(params, state)
const stateDisplay = entry.displayNames[state]
if (dynamicText && stateDisplay?.icon) {
return { text: dynamicText, icon: stateDisplay.icon }
}
}
// Fallback order (prefer pre-execution states for unknown states like pending)
const fallbackOrder: ClientToolCallState[] = [
(ClientToolCallState as any).generating,
(ClientToolCallState as any).executing,
(ClientToolCallState as any).review,
(ClientToolCallState as any).success,
(ClientToolCallState as any).error,
(ClientToolCallState as any).rejected,
]
for (const key of fallbackOrder) {
const cand = meta?.[key]
if (cand?.text || cand?.icon) return { text: cand.text, icon: cand.icon }
}
} catch {}
// Humanized fallback as last resort - include state verb for proper verb-noun styling
try {
if (toolName) {
const formattedName = toolName.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
// Add state verb prefix for verb-noun rendering in tool-call component
let stateVerb: string
switch (state) {
case ClientToolCallState.pending:
case ClientToolCallState.executing:
stateVerb = 'Executing'
break
case ClientToolCallState.success:
stateVerb = 'Executed'
break
case ClientToolCallState.error:
stateVerb = 'Failed'
break
case ClientToolCallState.rejected:
case ClientToolCallState.aborted:
stateVerb = 'Skipped'
break
default:
stateVerb = 'Executing'
}
return { text: `${stateVerb} ${formattedName}`, icon: undefined as any }
}
} catch {}
return undefined
// Exact state match
const display = entry.displayNames[state]
if (display?.text || display?.icon) return display
// Fallback through states
const fallbackOrder = [
ClientToolCallState.generating,
ClientToolCallState.executing,
ClientToolCallState.success,
]
for (const fallbackState of fallbackOrder) {
const fallback = entry.displayNames[fallbackState]
if (fallback?.text || fallback?.icon) return fallback
}
return humanizedFallback(toolName, state)
}
function humanizedFallback(
toolName: string,
state: ClientToolCallState
): ClientToolDisplay | undefined {
const formattedName = toolName.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
const stateVerb =
state === ClientToolCallState.success
? 'Executed'
: state === ClientToolCallState.error
? 'Failed'
: state === ClientToolCallState.rejected || state === ClientToolCallState.aborted
? 'Skipped'
: 'Executing'
return { text: `${stateVerb} ${formattedName}`, icon: undefined as any }
}
// Helper: check if a tool state is rejected
@@ -512,9 +288,8 @@ function abortAllInProgressTools(set: any, get: () => CopilotStore) {
/**
* Loads messages from DB for UI rendering.
* Messages are stored exactly as they render, so we just need to:
* 1. Register client tool instances for any tool calls
* 2. Clear any streaming flags (messages loaded from DB are never actively streaming)
* 3. Return the messages
* 1. Clear any streaming flags (messages loaded from DB are never actively streaming)
* 2. Return the messages
*/
function normalizeMessagesForUI(messages: CopilotMessage[]): CopilotMessage[] {
try {
@@ -530,12 +305,11 @@ function normalizeMessagesForUI(messages: CopilotMessage[]): CopilotMessage[] {
}
}
// Register client tool instances and clear streaming flags for all tool calls
// Clear streaming flags for all tool calls
for (const message of messages) {
if (message.contentBlocks) {
for (const block of message.contentBlocks as any[]) {
if (block?.type === 'tool_call' && block.toolCall) {
registerToolCallInstances(block.toolCall)
clearStreamingFlags(block.toolCall)
}
}
@@ -578,28 +352,6 @@ function clearStreamingFlags(toolCall: any): void {
}
}
/**
* Recursively registers client tool instances for a tool call and its nested subagent tool calls.
*/
function registerToolCallInstances(toolCall: any): void {
if (!toolCall?.id) return
ensureClientToolInstance(toolCall.name, toolCall.id)
// Register nested subagent tool calls
if (Array.isArray(toolCall.subAgentBlocks)) {
for (const block of toolCall.subAgentBlocks) {
if (block?.type === 'subagent_tool_call' && block.toolCall) {
registerToolCallInstances(block.toolCall)
}
}
}
if (Array.isArray(toolCall.subAgentToolCalls)) {
for (const subTc of toolCall.subAgentToolCalls) {
registerToolCallInstances(subTc)
}
}
}
// Simple object pool for content blocks
class ObjectPool<T> {
private pool: T[] = []
@@ -1431,9 +1183,6 @@ const sseHandlers: Record<string, SSEHandler> = {
if (!toolCallId || !toolName) return
const { toolCallsById } = get()
// Ensure class-based client tool instances are registered (for interrupts/display)
ensureClientToolInstance(toolName, toolCallId)
if (!toolCallsById[toolCallId]) {
// Show as pending until we receive full tool_call (with arguments) to decide execution
const initialState = ClientToolCallState.pending
@@ -1461,9 +1210,6 @@ const sseHandlers: Record<string, SSEHandler> = {
const isPartial = toolData.partial === true
const { toolCallsById } = get()
// Ensure class-based client tool instances are registered (for interrupts/display)
ensureClientToolInstance(name, id)
const existing = toolCallsById[id]
const next: CopilotToolCall = existing
? {
@@ -1939,9 +1685,6 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
context.subAgentBlocks[parentToolCallId] = []
}
// Ensure client tool instance is registered (for execution)
ensureClientToolInstance(name, id)
// Create or update the subagent tool call
const existingIndex = context.subAgentToolCalls[parentToolCallId].findIndex(
(tc) => tc.id === id
@@ -4287,56 +4030,3 @@ export const useCopilotStore = create<CopilotStore>()(
},
}))
)
// Sync class-based tool instance state changes back into the store map
try {
registerToolStateSync((toolCallId: string, nextState: any) => {
const state = useCopilotStore.getState()
const current = state.toolCallsById[toolCallId]
if (!current) return
let mapped: ClientToolCallState = current.state
if (nextState === 'executing') mapped = ClientToolCallState.executing
else if (nextState === 'pending') mapped = ClientToolCallState.pending
else if (nextState === 'success' || nextState === 'accepted')
mapped = ClientToolCallState.success
else if (nextState === 'error' || nextState === 'errored') mapped = ClientToolCallState.error
else if (nextState === 'rejected') mapped = ClientToolCallState.rejected
else if (nextState === 'aborted') mapped = ClientToolCallState.aborted
else if (nextState === 'review') mapped = (ClientToolCallState as any).review
else if (nextState === 'background') mapped = (ClientToolCallState as any).background
else if (typeof nextState === 'number') mapped = nextState as unknown as ClientToolCallState
// Store-authoritative gating: ignore invalid/downgrade transitions
const isTerminal = (s: ClientToolCallState) =>
s === ClientToolCallState.success ||
s === ClientToolCallState.error ||
s === ClientToolCallState.rejected ||
s === ClientToolCallState.aborted ||
(s as any) === (ClientToolCallState as any).review ||
(s as any) === (ClientToolCallState as any).background
// If we've already reached a terminal state, ignore any further non-terminal updates
if (isTerminal(current.state) && !isTerminal(mapped)) {
return
}
// Prevent downgrades (executing → pending, pending → generating)
if (
(current.state === ClientToolCallState.executing && mapped === ClientToolCallState.pending) ||
(current.state === ClientToolCallState.pending &&
mapped === (ClientToolCallState as any).generating)
) {
return
}
// No-op if unchanged
if (mapped === current.state) return
const updated = {
...state.toolCallsById,
[toolCallId]: {
...current,
state: mapped,
display: resolveToolDisplay(current.name, mapped, toolCallId, current.params),
},
}
useCopilotStore.setState({ toolCallsById: updated })
})
} catch {}

View File

@@ -1,7 +1,6 @@
import { createLogger } from '@sim/logger'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { getClientTool } from '@/lib/copilot/tools/client/manager'
import { stripWorkflowDiffMarkers, WorkflowDiffEngine } from '@/lib/workflows/diff'
import { enqueueReplaceWorkflowState } from '@/lib/workflows/operations/socket-operations'
import { validateWorkflowState } from '@/lib/workflows/sanitization/validation'
@@ -350,10 +349,12 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
findLatestEditWorkflowToolCallId().then((toolCallId) => {
if (toolCallId) {
getClientTool(toolCallId)
?.handleAccept?.()
?.catch?.((error: Error) => {
logger.warn('Failed to notify tool accept state', { error })
import('@/stores/panel/copilot/store')
.then(({ useCopilotStore }) => {
useCopilotStore.getState().updatePreviewToolCallState('accepted', toolCallId)
})
.catch((error) => {
logger.warn('Failed to update tool accept state', { error })
})
}
})
@@ -458,10 +459,12 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
findLatestEditWorkflowToolCallId().then((toolCallId) => {
if (toolCallId) {
getClientTool(toolCallId)
?.handleReject?.()
?.catch?.((error: Error) => {
logger.warn('Failed to notify tool reject state', { error })
import('@/stores/panel/copilot/store')
.then(({ useCopilotStore }) => {
useCopilotStore.getState().updatePreviewToolCallState('rejected', toolCallId)
})
.catch((error) => {
logger.warn('Failed to update tool reject state', { error })
})
}
})