mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-10 14:45:16 -05:00
Fix run workflow
This commit is contained in:
@@ -62,20 +62,23 @@ const ExecuteWorkflowSchema = z.object({
|
||||
runFromBlock: z
|
||||
.object({
|
||||
startBlockId: z.string().min(1, 'Start block ID is required'),
|
||||
sourceSnapshot: z.object({
|
||||
blockStates: z.record(z.any()),
|
||||
executedBlocks: z.array(z.string()),
|
||||
blockLogs: z.array(z.any()),
|
||||
decisions: z.object({
|
||||
router: z.record(z.string()),
|
||||
condition: z.record(z.string()),
|
||||
}),
|
||||
completedLoops: z.array(z.string()),
|
||||
loopExecutions: z.record(z.any()).optional(),
|
||||
parallelExecutions: z.record(z.any()).optional(),
|
||||
parallelBlockMapping: z.record(z.any()).optional(),
|
||||
activeExecutionPath: z.array(z.string()),
|
||||
}),
|
||||
sourceSnapshot: z
|
||||
.object({
|
||||
blockStates: z.record(z.any()),
|
||||
executedBlocks: z.array(z.string()),
|
||||
blockLogs: z.array(z.any()),
|
||||
decisions: z.object({
|
||||
router: z.record(z.string()),
|
||||
condition: z.record(z.string()),
|
||||
}),
|
||||
completedLoops: z.array(z.string()),
|
||||
loopExecutions: z.record(z.any()).optional(),
|
||||
parallelExecutions: z.record(z.any()).optional(),
|
||||
parallelBlockMapping: z.record(z.any()).optional(),
|
||||
activeExecutionPath: z.array(z.string()),
|
||||
})
|
||||
.optional(),
|
||||
executionId: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
@@ -269,9 +272,30 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
base64MaxBytes,
|
||||
workflowStateOverride,
|
||||
stopAfterBlockId,
|
||||
runFromBlock,
|
||||
runFromBlock: rawRunFromBlock,
|
||||
} = validation.data
|
||||
|
||||
// Resolve runFromBlock snapshot from executionId if needed
|
||||
let runFromBlock = rawRunFromBlock
|
||||
if (runFromBlock && !runFromBlock.sourceSnapshot && runFromBlock.executionId) {
|
||||
const {
|
||||
getExecutionState,
|
||||
getLatestExecutionState,
|
||||
} = await import('@/lib/workflows/executor/execution-state')
|
||||
const snapshot = runFromBlock.executionId === 'latest'
|
||||
? await getLatestExecutionState(id)
|
||||
: await getExecutionState(runFromBlock.executionId)
|
||||
if (!snapshot) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `No execution state found for ${runFromBlock.executionId === 'latest' ? 'workflow' : `execution ${runFromBlock.executionId}`}. Run the full workflow first.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
runFromBlock = { startBlockId: runFromBlock.startBlockId, sourceSnapshot: snapshot }
|
||||
}
|
||||
|
||||
// For API key and internal JWT auth, the entire body is the input (except for our control fields)
|
||||
// For session auth, the input is explicitly provided in the input field
|
||||
const input =
|
||||
|
||||
@@ -18,6 +18,10 @@ import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/component
|
||||
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
||||
import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import {
|
||||
CLIENT_EXECUTABLE_RUN_TOOLS,
|
||||
executeRunToolOnClient,
|
||||
} from '@/lib/copilot/client-sse/run-tool-execution'
|
||||
import type { CopilotToolCall } from '@/stores/panel'
|
||||
import { useCopilotStore } from '@/stores/panel'
|
||||
import type { SubAgentContentBlock } from '@/stores/panel/copilot/types'
|
||||
@@ -1277,6 +1281,14 @@ async function handleRun(
|
||||
setToolCallState(toolCall, 'executing', editedParams ? { params: editedParams } : undefined)
|
||||
onStateChange?.('executing')
|
||||
await sendToolDecision(toolCall.id, 'accepted')
|
||||
|
||||
// Client-executable run tools: execute on the client for real-time feedback
|
||||
// (block pulsing, console logs, stop button). The server defers execution
|
||||
// for these tools; the client reports back via mark-complete.
|
||||
if (CLIENT_EXECUTABLE_RUN_TOOLS.has(toolCall.name)) {
|
||||
const params = editedParams || toolCall.params || {}
|
||||
executeRunToolOnClient(toolCall.id, toolCall.name, params)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSkip(toolCall: CopilotToolCall, setToolCallState: any, onStateChange?: any) {
|
||||
|
||||
@@ -11,6 +11,12 @@ export interface WorkflowExecutionOptions {
|
||||
executionId?: string
|
||||
onBlockComplete?: (blockId: string, output: any) => Promise<void>
|
||||
overrideTriggerType?: 'chat' | 'manual' | 'api'
|
||||
stopAfterBlockId?: string
|
||||
/** For run_from_block / run_block: start from a specific block using cached state */
|
||||
runFromBlock?: {
|
||||
startBlockId: string
|
||||
executionId?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,6 +45,15 @@ export async function executeWorkflowWithFullLogging(
|
||||
triggerType: options.overrideTriggerType || 'manual',
|
||||
useDraftState: true,
|
||||
isClientSession: true,
|
||||
...(options.stopAfterBlockId ? { stopAfterBlockId: options.stopAfterBlockId } : {}),
|
||||
...(options.runFromBlock
|
||||
? {
|
||||
runFromBlock: {
|
||||
startBlockId: options.runFromBlock.startBlockId,
|
||||
executionId: options.runFromBlock.executionId || 'latest',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/workflows/${activeWorkflowId}/execute`, {
|
||||
|
||||
@@ -16,10 +16,15 @@ import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { appendTextBlock, beginThinkingBlock, finalizeThinkingBlock } from './content-blocks'
|
||||
import {
|
||||
CLIENT_EXECUTABLE_RUN_TOOLS,
|
||||
executeRunToolOnClient,
|
||||
} from './run-tool-execution'
|
||||
import type { ClientContentBlock, ClientStreamingContext } from './types'
|
||||
|
||||
const logger = createLogger('CopilotClientSseHandlers')
|
||||
const TEXT_BLOCK_TYPE = 'text'
|
||||
|
||||
const MAX_BATCH_INTERVAL = 50
|
||||
const MIN_BATCH_INTERVAL = 16
|
||||
const MAX_QUEUE_SIZE = 5
|
||||
@@ -408,6 +413,39 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Generate API key: update deployment status with the new key
|
||||
if (targetState === ClientToolCallState.success && current.name === 'generate_api_key') {
|
||||
try {
|
||||
const resultPayload = asRecord(
|
||||
data?.result || eventData.result || eventData.data || data?.data
|
||||
)
|
||||
const input = asRecord(current.params)
|
||||
const workflowId =
|
||||
(input?.workflowId as string) || useWorkflowRegistry.getState().activeWorkflowId
|
||||
const apiKey = (resultPayload?.apiKey || resultPayload?.key) as string | undefined
|
||||
if (workflowId) {
|
||||
const existingStatus =
|
||||
useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)
|
||||
useWorkflowRegistry
|
||||
.getState()
|
||||
.setDeploymentStatus(
|
||||
workflowId,
|
||||
existingStatus?.isDeployed ?? false,
|
||||
existingStatus?.deployedAt,
|
||||
apiKey
|
||||
)
|
||||
logger.info('[SSE] Updated deployment status with API key', {
|
||||
workflowId,
|
||||
hasKey: !!apiKey,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('[SSE] Failed to hydrate API key status', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < context.contentBlocks.length; i++) {
|
||||
@@ -588,6 +626,16 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
sendAutoAcceptConfirmation(id)
|
||||
}
|
||||
|
||||
// Client-executable run tools: execute on the client for real-time feedback
|
||||
// (block pulsing, console logs, stop button). The server defers execution
|
||||
// for these tools in interactive mode; the client reports back via mark-complete.
|
||||
if (
|
||||
CLIENT_EXECUTABLE_RUN_TOOLS.has(toolName) &&
|
||||
initialState === ClientToolCallState.executing
|
||||
) {
|
||||
executeRunToolOnClient(id, toolName, args || existing?.params || {})
|
||||
}
|
||||
|
||||
// OAuth: dispatch event to open the OAuth connect modal
|
||||
if (toolName === 'oauth_request_access' && args && typeof window !== 'undefined') {
|
||||
try {
|
||||
|
||||
220
apps/sim/lib/copilot/client-sse/run-tool-execution.ts
Normal file
220
apps/sim/lib/copilot/client-sse/run-tool-execution.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { executeWorkflowWithFullLogging } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils'
|
||||
import { COPILOT_CONFIRM_API_PATH } from '@/lib/copilot/constants'
|
||||
import { resolveToolDisplay } from '@/lib/copilot/store-utils'
|
||||
import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-display-registry'
|
||||
import { useExecutionStore } from '@/stores/execution/store'
|
||||
import { useCopilotStore } from '@/stores/panel/copilot/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('CopilotRunToolExecution')
|
||||
|
||||
/**
|
||||
* Run tools that execute client-side for real-time feedback
|
||||
* (block pulsing, logs, stop button, etc.).
|
||||
*/
|
||||
export const CLIENT_EXECUTABLE_RUN_TOOLS = new Set([
|
||||
'run_workflow',
|
||||
'run_workflow_until_block',
|
||||
'run_from_block',
|
||||
'run_block',
|
||||
])
|
||||
|
||||
/**
|
||||
* Execute a run tool on the client side using the streaming execute endpoint.
|
||||
* This gives full interactive feedback: block pulsing, console logs, stop button.
|
||||
*
|
||||
* Mirrors staging's RunWorkflowClientTool.handleAccept():
|
||||
* 1. Execute via executeWorkflowWithFullLogging
|
||||
* 2. Update client tool state directly (success/error)
|
||||
* 3. Report completion to server via /api/copilot/confirm (Redis),
|
||||
* where the server-side handler picks it up and tells Go
|
||||
*/
|
||||
export function executeRunToolOnClient(
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
params: Record<string, unknown>
|
||||
): void {
|
||||
doExecuteRunTool(toolCallId, toolName, params).catch((err) => {
|
||||
logger.error('[RunTool] Unhandled error in client-side run tool execution', {
|
||||
toolCallId,
|
||||
toolName,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function doExecuteRunTool(
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const { isExecuting, setIsExecuting } = useExecutionStore.getState()
|
||||
|
||||
if (isExecuting) {
|
||||
logger.warn('[RunTool] Execution prevented: already executing', { toolCallId, toolName })
|
||||
setToolState(toolCallId, ClientToolCallState.error)
|
||||
await reportCompletion(toolCallId, false, 'Workflow is already executing. Try again later')
|
||||
return
|
||||
}
|
||||
|
||||
const { activeWorkflowId } = useWorkflowRegistry.getState()
|
||||
if (!activeWorkflowId) {
|
||||
logger.warn('[RunTool] Execution prevented: no active workflow', { toolCallId, toolName })
|
||||
setToolState(toolCallId, ClientToolCallState.error)
|
||||
await reportCompletion(toolCallId, false, 'No active workflow found')
|
||||
return
|
||||
}
|
||||
|
||||
// Extract params for all tool types
|
||||
const workflowInput = (params.workflow_input || params.input || undefined) as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
|
||||
const stopAfterBlockId = (() => {
|
||||
if (toolName === 'run_workflow_until_block') return params.stopAfterBlockId as string | undefined
|
||||
if (toolName === 'run_block') return params.blockId as string | undefined
|
||||
return undefined
|
||||
})()
|
||||
|
||||
const runFromBlock = (() => {
|
||||
if (toolName === 'run_from_block' && params.startBlockId) {
|
||||
return {
|
||||
startBlockId: params.startBlockId as string,
|
||||
executionId: (params.executionId as string | undefined) || 'latest',
|
||||
}
|
||||
}
|
||||
if (toolName === 'run_block' && params.blockId) {
|
||||
return {
|
||||
startBlockId: params.blockId as string,
|
||||
executionId: (params.executionId as string | undefined) || 'latest',
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})()
|
||||
|
||||
setIsExecuting(true)
|
||||
const executionId = uuidv4()
|
||||
const executionStartTime = new Date().toISOString()
|
||||
|
||||
logger.info('[RunTool] Starting client-side workflow execution', {
|
||||
toolCallId,
|
||||
toolName,
|
||||
executionId,
|
||||
activeWorkflowId,
|
||||
hasInput: !!workflowInput,
|
||||
stopAfterBlockId,
|
||||
runFromBlock: runFromBlock ? { startBlockId: runFromBlock.startBlockId } : undefined,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await executeWorkflowWithFullLogging({
|
||||
workflowInput,
|
||||
executionId,
|
||||
stopAfterBlockId,
|
||||
runFromBlock,
|
||||
})
|
||||
|
||||
// Determine success (same logic as staging's RunWorkflowClientTool)
|
||||
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
|
||||
) {
|
||||
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.info('[RunTool] Workflow execution succeeded', { toolCallId, toolName })
|
||||
setToolState(toolCallId, ClientToolCallState.success)
|
||||
await reportCompletion(
|
||||
toolCallId,
|
||||
true,
|
||||
`Workflow execution completed. Started at: ${executionStartTime}`
|
||||
)
|
||||
} else {
|
||||
const msg = errorMessage || 'Workflow execution failed'
|
||||
logger.error('[RunTool] Workflow execution failed', { toolCallId, toolName, error: msg })
|
||||
setToolState(toolCallId, ClientToolCallState.error)
|
||||
await reportCompletion(toolCallId, false, msg)
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
logger.error('[RunTool] Workflow execution threw', { toolCallId, toolName, error: msg })
|
||||
setToolState(toolCallId, ClientToolCallState.error)
|
||||
await reportCompletion(toolCallId, false, msg)
|
||||
} finally {
|
||||
setIsExecuting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Update the tool call state directly in the copilot store (like staging's setState). */
|
||||
function setToolState(toolCallId: string, state: ClientToolCallState): void {
|
||||
try {
|
||||
const store = useCopilotStore.getState()
|
||||
const current = store.toolCallsById[toolCallId]
|
||||
if (!current) return
|
||||
const updated = {
|
||||
...store.toolCallsById,
|
||||
[toolCallId]: {
|
||||
...current,
|
||||
state,
|
||||
display: resolveToolDisplay(current.name, state, toolCallId, current.params),
|
||||
},
|
||||
}
|
||||
useCopilotStore.setState({ toolCallsById: updated })
|
||||
} catch (err) {
|
||||
logger.warn('[RunTool] Failed to update tool state', {
|
||||
toolCallId,
|
||||
state,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report tool completion to the server via the existing /api/copilot/confirm endpoint.
|
||||
* This writes {status: 'success'|'error', message} to Redis. The server-side handler
|
||||
* is polling Redis via waitForToolCompletion() and will pick this up, then fire-and-forget
|
||||
* markToolComplete to the Go backend.
|
||||
*/
|
||||
async function reportCompletion(
|
||||
toolCallId: string,
|
||||
success: boolean,
|
||||
message?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(COPILOT_CONFIRM_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
toolCallId,
|
||||
status: success ? 'success' : 'error',
|
||||
message: message || (success ? 'Tool completed' : 'Tool failed'),
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
logger.warn('[RunTool] reportCompletion failed', { toolCallId, status: res.status })
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[RunTool] reportCompletion error', {
|
||||
toolCallId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
sseHandlers,
|
||||
updateStreamingMessage,
|
||||
} from './handlers'
|
||||
import {
|
||||
CLIENT_EXECUTABLE_RUN_TOOLS,
|
||||
executeRunToolOnClient,
|
||||
} from './run-tool-execution'
|
||||
import type { ClientStreamingContext } from './types'
|
||||
|
||||
const logger = createLogger('CopilotClientSubagentHandlers')
|
||||
@@ -245,6 +249,13 @@ export const subAgentSSEHandlers: Record<string, SSEHandler> = {
|
||||
if (isAutoAllowed) {
|
||||
sendAutoAcceptConfirmation(id)
|
||||
}
|
||||
|
||||
// Client-executable run tools: execute on the client for real-time feedback.
|
||||
// The server defers execution in interactive mode; we execute here and
|
||||
// report back via mark-complete.
|
||||
if (CLIENT_EXECUTABLE_RUN_TOOLS.has(name)) {
|
||||
executeRunToolOnClient(id, name, args || {})
|
||||
}
|
||||
},
|
||||
|
||||
tool_result: (data, context, get, set) => {
|
||||
|
||||
@@ -20,10 +20,27 @@ import type {
|
||||
StreamingContext,
|
||||
ToolCallState,
|
||||
} from '@/lib/copilot/orchestrator/types'
|
||||
import { executeToolAndReport, isInterruptToolName, waitForToolDecision } from './tool-execution'
|
||||
import {
|
||||
executeToolAndReport,
|
||||
isInterruptToolName,
|
||||
waitForToolCompletion,
|
||||
waitForToolDecision,
|
||||
} from './tool-execution'
|
||||
|
||||
const logger = createLogger('CopilotSseHandlers')
|
||||
|
||||
/**
|
||||
* Run tools that can be executed client-side for real-time feedback
|
||||
* (block pulsing, logs, stop button). When interactive, the server defers
|
||||
* execution to the browser client instead of running executeWorkflow directly.
|
||||
*/
|
||||
const CLIENT_EXECUTABLE_RUN_TOOLS = new Set([
|
||||
'run_workflow',
|
||||
'run_workflow_until_block',
|
||||
'run_from_block',
|
||||
'run_block',
|
||||
])
|
||||
|
||||
// Normalization + dedupe helpers live in sse-utils to keep server/client in sync.
|
||||
|
||||
function inferToolSuccess(data: Record<string, unknown> | undefined): {
|
||||
@@ -182,6 +199,35 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
options.abortSignal
|
||||
)
|
||||
if (decision?.status === 'accepted' || decision?.status === 'success') {
|
||||
// Client-executable run tools: defer execution to the browser client.
|
||||
// The client calls executeWorkflowWithFullLogging for real-time feedback
|
||||
// (block pulsing, logs, stop button) and reports completion via
|
||||
// /api/copilot/confirm with status success/error. We poll Redis for
|
||||
// that completion signal, then fire-and-forget markToolComplete to Go.
|
||||
if (CLIENT_EXECUTABLE_RUN_TOOLS.has(toolName)) {
|
||||
toolCall.status = 'executing'
|
||||
const completion = await waitForToolCompletion(
|
||||
toolCallId,
|
||||
options.timeout || STREAM_TIMEOUT_MS,
|
||||
options.abortSignal
|
||||
)
|
||||
const success = completion?.status === 'success'
|
||||
toolCall.status = success ? 'success' : 'error'
|
||||
toolCall.endTime = Date.now()
|
||||
const msg =
|
||||
completion?.message || (success ? 'Tool completed' : 'Tool failed or timed out')
|
||||
// Fire-and-forget: tell Go backend the tool is done
|
||||
// (must NOT await — see deadlock note in executeToolAndReport)
|
||||
markToolComplete(toolCall.id, toolCall.name, success ? 200 : 500, msg).catch((err) => {
|
||||
logger.error('markToolComplete fire-and-forget failed (run tool)', {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
markToolResultSeen(toolCallId)
|
||||
return
|
||||
}
|
||||
await executeToolAndReport(toolCallId, context, execContext, options)
|
||||
return
|
||||
}
|
||||
@@ -435,6 +481,30 @@ export const subAgentHandlers: Record<string, SSEHandler> = {
|
||||
}
|
||||
}
|
||||
|
||||
// Client-executable run tools in interactive mode: defer to client.
|
||||
// Same pattern as main handler: wait for client completion, then tell Go.
|
||||
if (options.interactive === true && CLIENT_EXECUTABLE_RUN_TOOLS.has(toolName)) {
|
||||
toolCall.status = 'executing'
|
||||
const completion = await waitForToolCompletion(
|
||||
toolCallId,
|
||||
options.timeout || STREAM_TIMEOUT_MS,
|
||||
options.abortSignal
|
||||
)
|
||||
const success = completion?.status === 'success'
|
||||
toolCall.status = success ? 'success' : 'error'
|
||||
toolCall.endTime = Date.now()
|
||||
const msg = completion?.message || (success ? 'Tool completed' : 'Tool failed or timed out')
|
||||
markToolComplete(toolCall.id, toolCall.name, success ? 200 : 500, msg).catch((err) => {
|
||||
logger.error('markToolComplete fire-and-forget failed (subagent run tool)', {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
markToolResultSeen(toolCallId)
|
||||
return
|
||||
}
|
||||
|
||||
if (options.autoExecuteTools !== false) {
|
||||
await executeToolAndReport(toolCallId, context, execContext, options)
|
||||
}
|
||||
|
||||
@@ -144,3 +144,34 @@ export async function waitForToolDecision(
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a tool completion signal (success/error) from the client.
|
||||
* Unlike waitForToolDecision which returns on any status, this ignores
|
||||
* intermediate statuses like 'accepted'/'rejected'/'background' and only
|
||||
* returns when the client reports final completion via success/error.
|
||||
*
|
||||
* Used for client-executable run tools: the client executes the workflow
|
||||
* and posts success/error to /api/copilot/confirm when done. The server
|
||||
* polls here until that completion signal arrives.
|
||||
*/
|
||||
export async function waitForToolCompletion(
|
||||
toolCallId: string,
|
||||
timeoutMs: number,
|
||||
abortSignal?: AbortSignal
|
||||
): Promise<{ status: string; message?: string } | null> {
|
||||
const start = Date.now()
|
||||
let interval = TOOL_DECISION_INITIAL_POLL_MS
|
||||
const maxInterval = TOOL_DECISION_MAX_POLL_MS
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (abortSignal?.aborted) return null
|
||||
const decision = await getToolConfirmation(toolCallId)
|
||||
// Only return on completion statuses, not accept/reject decisions
|
||||
if (decision?.status === 'success' || decision?.status === 'error') {
|
||||
return decision
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, interval))
|
||||
interval = Math.min(interval * TOOL_DECISION_POLL_BACKOFF, maxInterval)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1697,6 +1697,225 @@ const META_research: ToolMetadata = {
|
||||
},
|
||||
}
|
||||
|
||||
const META_generate_api_key: ToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Preparing to generate API key', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Generate API key?', icon: KeyRound },
|
||||
[ClientToolCallState.executing]: { text: 'Generating API key', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Generated API key', icon: KeyRound },
|
||||
[ClientToolCallState.error]: { text: 'Failed to generate API key', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped generating API key', icon: MinusCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted generating API key', icon: XCircle },
|
||||
},
|
||||
interrupt: {
|
||||
accept: { text: 'Generate', icon: KeyRound },
|
||||
reject: { text: 'Skip', icon: MinusCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
interrupt: {
|
||||
accept: { text: 'Generate', icon: KeyRound },
|
||||
reject: { text: 'Skip', icon: MinusCircle },
|
||||
showAllowOnce: true,
|
||||
showAllowAlways: true,
|
||||
},
|
||||
},
|
||||
getDynamicText: (params, state) => {
|
||||
const name = params?.name
|
||||
if (name && typeof name === 'string') {
|
||||
switch (state) {
|
||||
case ClientToolCallState.success:
|
||||
return `Generated API key "${name}"`
|
||||
case ClientToolCallState.executing:
|
||||
return `Generating API key "${name}"`
|
||||
case ClientToolCallState.generating:
|
||||
return `Preparing to generate "${name}"`
|
||||
case ClientToolCallState.pending:
|
||||
return `Generate API key "${name}"?`
|
||||
case ClientToolCallState.error:
|
||||
return `Failed to generate "${name}"`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
|
||||
const META_run_block: ToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Preparing to run block', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Run this block?', icon: Play },
|
||||
[ClientToolCallState.executing]: { text: 'Running block', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Executed block', icon: Play },
|
||||
[ClientToolCallState.error]: { text: 'Failed to run block', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped block execution', icon: MinusCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted block execution', icon: MinusCircle },
|
||||
[ClientToolCallState.background]: { text: 'Running block 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 block execution to the background. Check back with them later to know when the block execution is complete',
|
||||
targetState: ClientToolCallState.background,
|
||||
},
|
||||
},
|
||||
getDynamicText: (params, state) => {
|
||||
const blockId = params?.blockId || params?.block_id
|
||||
if (blockId && typeof blockId === 'string') {
|
||||
switch (state) {
|
||||
case ClientToolCallState.success:
|
||||
return `Executed block ${blockId}`
|
||||
case ClientToolCallState.executing:
|
||||
return `Running block ${blockId}`
|
||||
case ClientToolCallState.generating:
|
||||
return `Preparing to run block ${blockId}`
|
||||
case ClientToolCallState.pending:
|
||||
return `Run block ${blockId}?`
|
||||
case ClientToolCallState.error:
|
||||
return `Failed to run block ${blockId}`
|
||||
case ClientToolCallState.rejected:
|
||||
return `Skipped running block ${blockId}`
|
||||
case ClientToolCallState.aborted:
|
||||
return `Aborted running block ${blockId}`
|
||||
case ClientToolCallState.background:
|
||||
return `Running block ${blockId} in background`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
|
||||
const META_run_from_block: ToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Preparing to run from block', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Run from this block?', icon: Play },
|
||||
[ClientToolCallState.executing]: { text: 'Running from block', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Executed from block', icon: Play },
|
||||
[ClientToolCallState.error]: { text: 'Failed to run from block', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped run from block', icon: MinusCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted run from block', icon: MinusCircle },
|
||||
[ClientToolCallState.background]: { text: 'Running from block 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,
|
||||
},
|
||||
},
|
||||
getDynamicText: (params, state) => {
|
||||
const blockId = params?.startBlockId || params?.start_block_id
|
||||
if (blockId && typeof blockId === 'string') {
|
||||
switch (state) {
|
||||
case ClientToolCallState.success:
|
||||
return `Executed from block ${blockId}`
|
||||
case ClientToolCallState.executing:
|
||||
return `Running from block ${blockId}`
|
||||
case ClientToolCallState.generating:
|
||||
return `Preparing to run from block ${blockId}`
|
||||
case ClientToolCallState.pending:
|
||||
return `Run from block ${blockId}?`
|
||||
case ClientToolCallState.error:
|
||||
return `Failed to run from block ${blockId}`
|
||||
case ClientToolCallState.rejected:
|
||||
return `Skipped running from block ${blockId}`
|
||||
case ClientToolCallState.aborted:
|
||||
return `Aborted running from block ${blockId}`
|
||||
case ClientToolCallState.background:
|
||||
return `Running from block ${blockId} in background`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
|
||||
const META_run_workflow_until_block: ToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Preparing to run until block', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Run until this block?', icon: Play },
|
||||
[ClientToolCallState.executing]: { text: 'Running until block', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Executed until block', icon: Play },
|
||||
[ClientToolCallState.error]: { text: 'Failed to run until block', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped run until block', icon: MinusCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted run until block', icon: MinusCircle },
|
||||
[ClientToolCallState.background]: { text: 'Running until block 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,
|
||||
},
|
||||
},
|
||||
getDynamicText: (params, state) => {
|
||||
const blockId = params?.stopAfterBlockId || params?.stop_after_block_id
|
||||
if (blockId && typeof blockId === 'string') {
|
||||
switch (state) {
|
||||
case ClientToolCallState.success:
|
||||
return `Executed until block ${blockId}`
|
||||
case ClientToolCallState.executing:
|
||||
return `Running until block ${blockId}`
|
||||
case ClientToolCallState.generating:
|
||||
return `Preparing to run until block ${blockId}`
|
||||
case ClientToolCallState.pending:
|
||||
return `Run until block ${blockId}?`
|
||||
case ClientToolCallState.error:
|
||||
return `Failed to run until block ${blockId}`
|
||||
case ClientToolCallState.rejected:
|
||||
return `Skipped running until block ${blockId}`
|
||||
case ClientToolCallState.aborted:
|
||||
return `Aborted running until block ${blockId}`
|
||||
case ClientToolCallState.background:
|
||||
return `Running until block ${blockId} in background`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
|
||||
const META_run_workflow: ToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Preparing to run your workflow', icon: Loader2 },
|
||||
@@ -2310,6 +2529,7 @@ const TOOL_METADATA_BY_ID: Record<string, ToolMetadata> = {
|
||||
get_blocks_and_tools: META_get_blocks_and_tools,
|
||||
get_blocks_metadata: META_get_blocks_metadata,
|
||||
get_credentials: META_get_credentials,
|
||||
generate_api_key: META_generate_api_key,
|
||||
get_examples_rag: META_get_examples_rag,
|
||||
get_operations_examples: META_get_operations_examples,
|
||||
get_page_contents: META_get_page_contents,
|
||||
@@ -2335,7 +2555,10 @@ const TOOL_METADATA_BY_ID: Record<string, ToolMetadata> = {
|
||||
redeploy: META_redeploy,
|
||||
remember_debug: META_remember_debug,
|
||||
research: META_research,
|
||||
run_block: META_run_block,
|
||||
run_from_block: META_run_from_block,
|
||||
run_workflow: META_run_workflow,
|
||||
run_workflow_until_block: META_run_workflow_until_block,
|
||||
scrape_page: META_scrape_page,
|
||||
search_documentation: META_search_documentation,
|
||||
search_errors: META_search_errors,
|
||||
|
||||
Reference in New Issue
Block a user