Files
simstudioai--sim/apps/sim/lib/copilot/request/handlers/tool.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

561 lines
18 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/copilot/async-runs/lifecycle'
import { markAsyncToolDelivered, upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository'
import { STREAM_TIMEOUT_MS } from '@/lib/copilot/constants'
import {
MothershipStreamV1AsyncToolRecordStatus,
type MothershipStreamV1ToolCallDescriptor,
MothershipStreamV1ToolOutcome,
type MothershipStreamV1ToolResultPayload,
} from '@/lib/copilot/generated/mothership-stream-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { withCopilotSpan } from '@/lib/copilot/request/otel'
import {
isToolArgsDeltaStreamEvent,
isToolCallStreamEvent,
isToolResultStreamEvent,
TOOL_CALL_STATUS,
} from '@/lib/copilot/request/session'
import { markToolResultSeen, wasToolResultSeen } from '@/lib/copilot/request/sse-utils'
import { setTerminalToolCallState } from '@/lib/copilot/request/tool-call-state'
import { executeToolAndReport, waitForToolCompletion } from '@/lib/copilot/request/tools/executor'
import type {
ExecutionContext,
OrchestratorOptions,
StreamEvent,
StreamingContext,
ToolCallState,
} from '@/lib/copilot/request/types'
import { getToolEntry, isSimExecuted } from '@/lib/copilot/tool-executor'
import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools'
import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display'
import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools'
import type { ToolScope } from './types'
import {
abortPendingToolIfStreamDead,
addContentBlock,
emitSyntheticToolResult,
ensureTerminalToolCallState,
flushSubagentThinkingBlock,
flushThinkingBlock,
getScopedParentToolCallId,
getScopedSpanIdentity,
getToolCallUI,
getToolResultErrorMessage,
handleClientCompletion,
inferToolSuccess,
registerPendingToolPromise,
} from './types'
const logger = createLogger('CopilotToolHandler')
function applyToolDisplay(toolCall: ToolCallState | undefined): void {
if (!toolCall?.name) return
toolCall.displayTitle = getToolDisplayTitle(
toolCall.name,
toolCall.params as Record<string, unknown> | undefined
)
}
/**
* Upsert the durable `async_tool_calls` row before the authoritative tool-call
* SSE frame is forwarded to the client, so `/api/copilot/confirm` can never
* race ahead of the row that identifies the call. This is the sole
* persistence point for client-executable tools; gating mirrors the
* client-wait branch in `dispatchToolExecution`.
*/
export async function prePersistClientExecutableToolCall(
event: StreamEvent,
context: StreamingContext
): Promise<void> {
if (event.type !== 'tool') return
if (!isToolCallStreamEvent(event)) return
const data = event.payload
const isGenerating = data.status === TOOL_CALL_STATUS.generating
const isPartial = data.partial === true || isGenerating
if (isPartial) return
const ui = getToolCallUI(data)
if (!ui.clientExecutable) return
const catalogEntry = getToolEntry(data.toolName)
const isInternal = ui.internal === true || catalogEntry?.internal === true
if (isInternal) return
const delegateWorkflowRunToClient = isWorkflowToolName(data.toolName)
if (isSimExecuted(data.toolName) && !delegateWorkflowRunToClient) return
if (!context.runId) return
await upsertAsyncToolCall({
runId: context.runId,
toolCallId: data.toolCallId,
toolName: data.toolName,
args: data.arguments,
status: MothershipStreamV1AsyncToolRecordStatus.running,
}).catch((err) => {
logger.warn('Failed to pre-persist async tool row before forwarding call frame', {
toolCallId: data.toolCallId,
toolName: data.toolName,
error: getErrorMessage(err),
})
})
}
/**
* Unified tool event handler for both main and subagent scopes.
*
* The main vs subagent differences are:
* - Subagent requires a parentToolCallId and tracks tool calls in subAgentToolCalls
* - Subagent result phase also updates the subAgentToolCalls record
* - Subagent call phase stores in both subAgentToolCalls and context.toolCalls
* - Main call phase only stores in context.toolCalls
*/
export async function handleToolEvent(
event: StreamEvent,
context: StreamingContext,
execContext: ExecutionContext,
options: OrchestratorOptions,
scope: ToolScope
): Promise<void> {
const isSubagent = scope === 'subagent'
const parentToolCallId = isSubagent ? getScopedParentToolCallId(event, context) : undefined
if (isSubagent && !parentToolCallId) return
if (event.type !== 'tool') {
return
}
if (isToolArgsDeltaStreamEvent(event)) {
return
}
// A tool event breaks the thinking stream. Flush any open thinking
// block into contentBlocks BEFORE we add the tool_call block, or
// contentBlocks will end up with tool_call before thinking — which
// re-renders on reload in the wrong order (Mothership group above
// the Thinking block, even though thinking happened first). A subagent
// tool event flushes only its OWN lane so a concurrent sibling's thinking
// is left intact; a main tool event flushes all subagent lanes.
if (isSubagent && parentToolCallId) {
flushSubagentThinkingBlock(context, parentToolCallId)
} else {
flushSubagentThinkingBlock(context)
}
flushThinkingBlock(context)
if (isToolResultStreamEvent(event)) {
handleResultPhase(event.payload, context, parentToolCallId)
return
}
if (!isToolCallStreamEvent(event)) {
return
}
if (!parentToolCallId) {
context.sawMainToolCall = true
context.finalAssistantContent = ''
}
await handleCallPhase(
event.payload,
context,
execContext,
options,
parentToolCallId,
scope,
getScopedSpanIdentity(event)
)
}
function handleResultPhase(
data: MothershipStreamV1ToolResultPayload,
context: StreamingContext,
parentToolCallId: string | undefined
): void {
const { toolCallId, toolName } = data
const mainToolCall = ensureTerminalToolCallState(context, toolCallId, toolName)
const { success, hasResultData } = inferToolSuccess(data)
let status: MothershipStreamV1ToolOutcome
if (data.status === MothershipStreamV1ToolOutcome.cancelled) {
status = MothershipStreamV1ToolOutcome.cancelled
} else if (data.status === MothershipStreamV1ToolOutcome.skipped) {
status = MothershipStreamV1ToolOutcome.skipped
} else if (data.status === MothershipStreamV1ToolOutcome.rejected) {
status = MothershipStreamV1ToolOutcome.rejected
} else {
status = success ? MothershipStreamV1ToolOutcome.success : MothershipStreamV1ToolOutcome.error
}
const endTime = Date.now()
const errorMessage =
!success && status !== MothershipStreamV1ToolOutcome.skipped
? getToolResultErrorMessage(data) ||
(status === MothershipStreamV1ToolOutcome.cancelled
? 'Tool cancelled'
: status === MothershipStreamV1ToolOutcome.rejected
? 'Tool rejected'
: 'Tool failed')
: undefined
if (parentToolCallId) {
const toolCalls = context.subAgentToolCalls[parentToolCallId] || []
const subAgentToolCall = toolCalls.find((tc) => tc.id === toolCallId)
if (subAgentToolCall) {
setTerminalToolCallState(subAgentToolCall, {
status,
...(hasResultData ? { output: data.output } : {}),
...(errorMessage ? { error: errorMessage } : {}),
endTime,
})
}
}
setTerminalToolCallState(mainToolCall, {
status,
...(hasResultData ? { output: data.output } : {}),
...(errorMessage ? { error: errorMessage } : {}),
endTime,
})
stampToolCallBlockEnd(context, toolCallId, endTime)
markToolResultSeen(toolCallId)
}
function stampToolCallBlockEnd(
context: StreamingContext,
toolCallId: string,
endTime: number
): void {
for (let i = context.contentBlocks.length - 1; i >= 0; i--) {
const block = context.contentBlocks[i]
if (block.type === 'tool_call' && block.toolCall?.id === toolCallId) {
if (block.endedAt === undefined) block.endedAt = endTime
return
}
}
}
async function handleCallPhase(
data: MothershipStreamV1ToolCallDescriptor,
context: StreamingContext,
execContext: ExecutionContext,
options: OrchestratorOptions,
parentToolCallId: string | undefined,
scope: ToolScope,
spanIdentity: { spanId?: string; parentSpanId?: string }
): Promise<void> {
const { toolCallId, toolName } = data
const args = data.arguments
const isGenerating = data.status === TOOL_CALL_STATUS.generating
const isPartial = data.partial === true || isGenerating
const existing = context.toolCalls.get(toolCallId)
const isSubagent = scope === 'subagent'
const ui = getToolCallUI(data)
if (isPartial && shouldDelayVfsPlaceholder(toolName, args)) return
if (isSubagent) {
if (wasToolResultSeen(toolCallId) || existing?.endTime) {
if (existing && !existing.name && toolName) existing.name = toolName
if (existing && !existing.params && args) existing.params = args
applyToolDisplay(existing)
return
}
} else {
if (
existing?.endTime ||
(existing && existing.status !== 'pending' && existing.status !== 'executing')
) {
if (!existing.name && toolName) existing.name = toolName
if (!existing.params && args) existing.params = args
applyToolDisplay(existing)
return
}
}
if (isSubagent) {
registerSubagentToolCall(
context,
toolCallId,
toolName,
args,
parentToolCallId!,
ui,
spanIdentity
)
} else {
registerMainToolCall(context, toolCallId, toolName, args, existing, ui)
}
if (isPartial) return
if (!isSubagent && wasToolResultSeen(toolCallId)) return
if (context.pendingToolPromises.has(toolCallId) || existing?.status === 'executing') {
return
}
const toolCall = context.toolCalls.get(toolCallId)
if (!toolCall) return
// Capture the invoking subagent's channel id so the executor can thread it
// into the server tool context — this is what scopes the workspace_file ->
// edit_content intent handoff to one file subagent under concurrency.
if (parentToolCallId) toolCall.parentToolCallId = parentToolCallId
const readPath = typeof args?.path === 'string' ? args.path : undefined
if (toolName === 'read' && readPath?.startsWith('internal/')) return
const { clientExecutable, simExecutable, internal } = ui
const catalogEntry = getToolEntry(toolName)
const isInternal = internal || catalogEntry?.internal === true
const staticSimExecuted = isSimExecuted(toolName)
const willDispatch = !isInternal && (staticSimExecuted || simExecutable || clientExecutable)
logger.info('Tool call routing decision', {
toolCallId,
toolName,
scope,
isSubagent,
parentToolCallId,
executor: data.executor,
clientExecutable,
simExecutable,
staticSimExecuted,
internal: isInternal,
hasPendingPromise: context.pendingToolPromises.has(toolCallId),
existingStatus: existing?.status,
willDispatch,
})
if (isInternal) return
if (!willDispatch) return
await dispatchToolExecution(
toolCall,
toolCallId,
toolName,
args,
context,
execContext,
options,
clientExecutable,
scope
)
}
function shouldDelayVfsPlaceholder(
toolName: string,
args: Record<string, unknown> | undefined
): boolean {
return (toolName === 'read' || toolName === 'glob') && !args
}
function removeToolCallContentBlock(context: StreamingContext, toolCallId: string): void {
for (let i = context.contentBlocks.length - 1; i >= 0; i--) {
const block = context.contentBlocks[i]
if (block.type === 'tool_call' && block.toolCall?.id === toolCallId) {
context.contentBlocks.splice(i, 1)
}
}
}
function registerSubagentToolCall(
context: StreamingContext,
toolCallId: string,
toolName: string,
args: Record<string, unknown> | undefined,
parentToolCallId: string,
ui: { title?: string; phaseLabel?: string; hidden?: boolean },
spanIdentity: { spanId?: string; parentSpanId?: string }
): void {
if (!context.subAgentToolCalls[parentToolCallId]) {
context.subAgentToolCalls[parentToolCallId] = []
}
const hideFromUi = isToolHiddenInUi(toolName) || ui.hidden === true
let toolCall = context.toolCalls.get(toolCallId)
if (toolCall) {
if (!toolCall.name && toolName) toolCall.name = toolName
if (args && !toolCall.params) toolCall.params = args
applyToolDisplay(toolCall)
if (hideFromUi) removeToolCallContentBlock(context, toolCallId)
} else {
toolCall = {
id: toolCallId,
name: toolName,
status: 'pending',
params: args,
startTime: Date.now(),
}
applyToolDisplay(toolCall)
context.toolCalls.set(toolCallId, toolCall)
const parentToolCall = context.toolCalls.get(parentToolCallId)
if (!hideFromUi) {
addContentBlock(context, {
type: 'tool_call',
toolCall,
calledBy: parentToolCall?.name,
parentToolCallId,
...spanIdentity,
})
}
}
const subagentToolCalls = context.subAgentToolCalls[parentToolCallId]
const existingSubagentToolCall = subagentToolCalls.find((tc) => tc.id === toolCallId)
if (existingSubagentToolCall) {
if (!existingSubagentToolCall.name && toolName) existingSubagentToolCall.name = toolName
if (args && !existingSubagentToolCall.params) existingSubagentToolCall.params = args
applyToolDisplay(existingSubagentToolCall)
} else {
subagentToolCalls.push(toolCall)
}
}
function registerMainToolCall(
context: StreamingContext,
toolCallId: string,
toolName: string,
args: Record<string, unknown> | undefined,
existing: ToolCallState | undefined,
ui: { title?: string; phaseLabel?: string; hidden?: boolean }
): void {
const hideFromUi = isToolHiddenInUi(toolName) || ui.hidden === true
if (existing) {
if (args && !existing.params) existing.params = args
applyToolDisplay(existing)
if (hideFromUi) {
removeToolCallContentBlock(context, toolCallId)
return
}
if (
!hideFromUi &&
!context.contentBlocks.some((b) => b.type === 'tool_call' && b.toolCall?.id === toolCallId)
) {
addContentBlock(context, { type: 'tool_call', toolCall: existing })
}
} else {
const created: ToolCallState = {
id: toolCallId,
name: toolName,
status: 'pending',
params: args,
startTime: Date.now(),
}
applyToolDisplay(created)
context.toolCalls.set(toolCallId, created)
if (!hideFromUi) {
addContentBlock(context, { type: 'tool_call', toolCall: created })
}
}
}
async function dispatchToolExecution(
toolCall: ToolCallState,
toolCallId: string,
toolName: string,
args: Record<string, unknown> | undefined,
context: StreamingContext,
execContext: ExecutionContext,
options: OrchestratorOptions,
clientExecutable: boolean,
scope: ToolScope
): Promise<void> {
const scopeLabel = scope === 'subagent' ? 'subagent ' : ''
const fireToolExecution = () => {
const pendingPromise = (async () => {
return executeToolAndReport(toolCallId, context, execContext, options)
})().catch((err) => {
logger.error(`Parallel ${scopeLabel}tool execution failed`, {
toolCallId,
toolName,
error: toError(err).message,
})
return {
status: MothershipStreamV1ToolOutcome.error,
message: 'Tool execution failed',
data: { error: 'Tool execution failed' },
}
})
registerPendingToolPromise(context, toolCallId, pendingPromise)
}
if (options.interactive === false) {
if (options.autoExecuteTools !== false) {
if (!abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) {
fireToolExecution()
}
}
return
}
if (clientExecutable) {
const delegateWorkflowRunToClient = isWorkflowToolName(toolName)
if (isSimExecuted(toolName) && !delegateWorkflowRunToClient) {
if (!abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) {
fireToolExecution()
}
} else {
toolCall.status = 'executing'
const pendingPromise = withCopilotSpan(
TraceSpan.CopilotToolWaitForClientResult,
{
[TraceAttr.ToolName]: toolName,
[TraceAttr.ToolCallId]: toolCallId,
[TraceAttr.ToolTimeoutMs]: options.timeout || STREAM_TIMEOUT_MS,
...(context.runId ? { [TraceAttr.RunId]: context.runId } : {}),
},
async (span) => {
const completion = await waitForToolCompletion(
toolCallId,
options.timeout || STREAM_TIMEOUT_MS,
options.abortSignal
)
span.setAttribute(TraceAttr.ToolCompletionReceived, completion !== undefined)
if (completion) {
span.setAttribute(TraceAttr.ToolOutcome, completion.status)
}
handleClientCompletion(toolCall, toolCallId, completion)
if (completion?.status === ASYNC_TOOL_CONFIRMATION_STATUS.background) {
await markAsyncToolDelivered(toolCallId).catch((err) => {
logger.warn(`Failed to mark background ${scopeLabel}tool delivered`, {
toolCallId,
toolName,
error: toError(err).message,
})
})
}
await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options)
return (
completion ?? {
status: MothershipStreamV1ToolOutcome.error,
message: 'Tool completion missing',
data: { error: 'Tool completion missing' },
}
)
}
).catch((err) => {
logger.error(`Client-executable ${scopeLabel}tool wait failed`, {
toolCallId,
toolName,
error: toError(err).message,
})
return {
status: MothershipStreamV1ToolOutcome.error,
message: 'Tool wait failed',
data: { error: 'Tool wait failed' },
}
})
registerPendingToolPromise(context, toolCallId, pendingPromise)
}
return
}
if (options.autoExecuteTools !== false) {
if (!abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) {
fireToolExecution()
}
}
}