chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,96 @@
import { createLogger } from '@sim/logger'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { isEnterprise, isPaid } from '@/lib/billing/plan-helpers'
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
import {
MothershipStreamV1CompletionStatus,
MothershipStreamV1EventType,
MothershipStreamV1TextChannel,
} from '@/lib/copilot/generated/mothership-stream-v1'
import { sseHandlers } from '@/lib/copilot/request/handlers'
import type {
ExecutionContext,
OrchestratorOptions,
StreamEvent,
StreamingContext,
} from '@/lib/copilot/request/types'
const logger = createLogger('CopilotBillingEffect')
/**
* Handle a 402 billing-limit response from the Go backend.
*
* Determines whether the user needs a plan upgrade or a limit increase,
* then dispatches synthetic text + complete events through the handler chain
* so the client renders the upgrade prompt.
*/
export async function handleBillingLimitResponse(
userId: string,
context: StreamingContext,
execContext: ExecutionContext,
options: OrchestratorOptions
): Promise<void> {
let action: 'upgrade_plan' | 'increase_limit' = 'upgrade_plan'
let message = "You've reached your usage limit. Please upgrade your plan to continue."
try {
const sub = await getHighestPrioritySubscription(userId)
if (sub && isPaid(sub.plan)) {
// Paid subs use the existing `increase_limit` action so the UI
// (`UsageUpgradeDisplay`) renders its standard button. The message
// text does the work of clarifying the action when the user can't
// actually self-serve the limit change.
action = 'increase_limit'
const orgScoped = isOrgScopedSubscription(sub, userId)
if (orgScoped) {
message = isEnterprise(sub.plan)
? "You've reached your organization's usage limit for this billing period. Only an organization admin or Sim support can raise an enterprise limit — reach out to them to continue."
: "You've reached your organization's usage limit for this billing period. Only an organization owner or admin can raise the limit — please ask them to update it from the team billing settings."
} else {
message =
"You've reached your usage limit for this billing period. Please increase your usage limit from billing settings to continue."
}
}
} catch {
logger.warn('Failed to determine subscription plan, defaulting to upgrade_plan')
}
const upgradePayload = JSON.stringify({
reason: 'usage_limit',
action,
message,
})
const syntheticContent = `<usage_upgrade>${upgradePayload}</usage_upgrade>`
const syntheticEvents: StreamEvent[] = [
{
type: MothershipStreamV1EventType.text,
payload: {
channel: MothershipStreamV1TextChannel.assistant,
text: syntheticContent,
},
},
{
type: MothershipStreamV1EventType.complete,
payload: {
status: MothershipStreamV1CompletionStatus.complete,
},
},
]
for (const event of syntheticEvents) {
try {
await options.onEvent?.(event)
} catch {
logger.warn('Failed to forward synthetic billing event', { type: event.type })
}
// TODO: Handler dispatch should move out of this effect — effects should be
// pure side-effect producers; event dispatch belongs in the stream loop or
// a dedicated dispatcher. Keeping here for now to preserve behavior.
const handler = sseHandlers[event.type]
if (handler) {
await handler(event, context, execContext, options)
}
if (context.streamComplete) break
}
}
@@ -0,0 +1,33 @@
import {
ASYNC_TOOL_CONFIRMATION_STATUS,
type AsyncTerminalCompletionSnapshot,
isAsyncTerminalConfirmationStatus,
} from '@/lib/copilot/async-runs/lifecycle'
import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1'
import { waitForToolConfirmation } from '@/lib/copilot/persistence/tool-confirm'
/**
* Wait for a client-executable workflow tool to report back.
*
* Current browser runtime outcomes are:
* - `success`, `error`, `cancelled`: the workflow finished in the browser
* - `background`: the browser detached on `pagehide`, so the server should stop
* waiting for a foreground result
*/
export async function waitForToolCompletion(
toolCallId: string,
timeoutMs: number,
abortSignal?: AbortSignal
): Promise<AsyncTerminalCompletionSnapshot | null> {
const decision = await waitForToolConfirmation(toolCallId, timeoutMs, abortSignal, {
acceptStatus: (status) =>
status === MothershipStreamV1ToolOutcome.success ||
status === MothershipStreamV1ToolOutcome.error ||
status === ASYNC_TOOL_CONFIRMATION_STATUS.background ||
status === MothershipStreamV1ToolOutcome.cancelled,
})
if (decision && isAsyncTerminalConfirmationStatus(decision.status)) {
return { ...decision, status: decision.status }
}
return null
}
@@ -0,0 +1,864 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { isRecordLike } from '@sim/utils/object'
import type {
AsyncCompletionEnvelope,
AsyncCompletionSignal,
} from '@/lib/copilot/async-runs/lifecycle'
import {
completeAsyncToolCall,
markAsyncToolRunning,
upsertAsyncToolCall,
} from '@/lib/copilot/async-runs/repository'
import { TOOL_WATCHDOG_DEFAULT_MS, TOOL_WATCHDOG_LONG_RUNNING_MS } from '@/lib/copilot/constants'
import {
MothershipStreamV1AsyncToolRecordStatus,
MothershipStreamV1EventType,
MothershipStreamV1ToolExecutor,
MothershipStreamV1ToolMode,
MothershipStreamV1ToolOutcome,
MothershipStreamV1ToolPhase,
} from '@/lib/copilot/generated/mothership-stream-v1'
import {
CrawlWebsite,
CreateFile,
CreateWorkflow,
DownloadToWorkspaceFile,
EditContent,
Ffmpeg,
FunctionExecute,
GenerateAudio,
GenerateImage,
GenerateVideo,
KnowledgeBase,
MaterializeFile,
Media,
Research,
Run,
RunBlock,
RunFromBlock,
RunWorkflow,
RunWorkflowUntilBlock,
WorkspaceFile,
} from '@/lib/copilot/generated/tool-catalog-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { publishToolConfirmation } from '@/lib/copilot/persistence/tool-confirm'
import { recordSimToolMetric } from '@/lib/copilot/request/metrics'
import { withCopilotToolSpan } from '@/lib/copilot/request/otel'
import { markToolResultSeen } from '@/lib/copilot/request/sse-utils'
import {
getToolCallStateOutput,
getToolCallTerminalData,
requireToolCallError,
setTerminalToolCallState,
} from '@/lib/copilot/request/tool-call-state'
import { maybeWriteOutputToFile } from '@/lib/copilot/request/tools/files'
import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources'
import {
maybeWriteOutputToTable,
maybeWriteReadCsvToTable,
} from '@/lib/copilot/request/tools/tables'
import {
type ExecutionContext,
isTerminalToolCallStatus,
type OrchestratorOptions,
type StreamEvent,
type StreamingContext,
type ToolCallState,
} from '@/lib/copilot/request/types'
import { ensureHandlersRegistered, executeTool } from '@/lib/copilot/tool-executor'
export { waitForToolCompletion } from '@/lib/copilot/request/tools/client'
const logger = createLogger('CopilotSseToolExecution')
function hasOutputValue(result: { output?: unknown } | undefined): result is { output: unknown } {
return result !== undefined && Object.hasOwn(result, 'output')
}
interface ToolResultSpanSummary {
resultSuccess: boolean
outputBytes: number
outputKind: string
errorMessage?: string
imageCount?: number
imageBytes?: number
attachmentMediaType?: string
}
function summarizeToolResultForSpan(result: {
success: boolean
output?: unknown
error?: string
}): ToolResultSpanSummary {
const summary: ToolResultSpanSummary = {
resultSuccess: Boolean(result.success),
outputBytes: 0,
outputKind: 'none',
}
if (!result.success && result.error) {
summary.errorMessage = String(result.error).slice(0, 500)
}
if (!hasOutputValue(result)) {
return summary
}
const output = (result as { output: unknown }).output
if (typeof output === 'string') {
summary.outputKind = 'string'
summary.outputBytes = output.length
} else if (output && typeof output === 'object') {
summary.outputKind = Array.isArray(output) ? 'array' : 'object'
try {
summary.outputBytes = JSON.stringify(output).length
} catch {
summary.outputBytes = 0
}
const attachment = extractAttachmentShape(output)
if (attachment) {
summary.imageCount = attachment.imageCount
summary.imageBytes = attachment.imageBytes
if (attachment.mediaType) {
summary.attachmentMediaType = attachment.mediaType
}
}
} else if (output !== undefined && output !== null) {
summary.outputKind = typeof output
summary.outputBytes = String(output).length
}
return summary
}
function extractAttachmentShape(
output: unknown
): { imageCount: number; imageBytes: number; mediaType?: string } | null {
if (!isRecordLike(output)) return null
const candidate = (output as Record<string, unknown>).attachment
if (!isRecordLike(candidate)) return null
const source = (candidate as Record<string, unknown>).source
if (!isRecordLike(source)) return null
const type =
typeof (candidate as Record<string, unknown>).type === 'string'
? ((candidate as Record<string, unknown>).type as string)
: ''
if (type !== 'image') return null
const mediaType =
typeof source.media_type === 'string' ? (source.media_type as string) : undefined
const data = typeof source.data === 'string' ? (source.data as string) : ''
return {
imageCount: 1,
imageBytes: data.length,
mediaType,
}
}
function buildCompletionSignal(input: {
status: AsyncCompletionSignal['status']
message?: string
data?: unknown
}): AsyncCompletionSignal {
return {
status: input.status,
...(input.message !== undefined ? { message: input.message } : {}),
...(input.data !== undefined ? { data: input.data } : {}),
}
}
function getCreateWorkflowOutput(
output: unknown
): { workflowId?: string; workspaceId?: string } | undefined {
if (!isRecordLike(output)) {
return undefined
}
const workflowId = typeof output.workflowId === 'string' ? output.workflowId : undefined
const workspaceId = typeof output.workspaceId === 'string' ? output.workspaceId : undefined
if (!workflowId && !workspaceId) {
return undefined
}
return {
...(workflowId ? { workflowId } : {}),
...(workspaceId ? { workspaceId } : {}),
}
}
export interface AsyncToolCompletion extends AsyncCompletionSignal {}
function publishTerminalToolConfirmation(input: {
toolCallId: string
status: AsyncCompletionEnvelope['status']
message?: string
data?: unknown
}): void {
publishToolConfirmation({
toolCallId: input.toolCallId,
status: input.status,
message: input.message,
data: input.data,
timestamp: new Date().toISOString(),
})
}
function abortRequested(
context: StreamingContext,
execContext: ExecutionContext,
options?: OrchestratorOptions
): boolean {
return Boolean(
options?.abortSignal?.aborted || execContext.abortSignal?.aborted || context.wasAborted
)
}
/**
* Tool classes whose legitimate runtime can far exceed the default watchdog:
* workflow executions, sandboxed code, media/image/audio generation, deep
* research, large downloads, knowledge-base indexing, and file-content
* producers (create/edit/materialize hit the E2B doc compile/recalc/render
* pipeline on doc-backed files). They get the long watchdog cap; everything
* else (read/glob/grep/metadata CRUD/...) must settle within the strict
* default or be failed so the run can continue.
*/
const LONG_RUNNING_TOOL_IDS: ReadonlySet<string> = new Set([
Run.id,
RunBlock.id,
RunFromBlock.id,
RunWorkflow.id,
RunWorkflowUntilBlock.id,
FunctionExecute.id,
GenerateImage.id,
GenerateAudio.id,
GenerateVideo.id,
Ffmpeg.id,
Media.id,
Research.id,
CrawlWebsite.id,
KnowledgeBase.id,
DownloadToWorkspaceFile.id,
CreateFile.id,
EditContent.id,
MaterializeFile.id,
WorkspaceFile.id,
])
export function toolWatchdogTimeoutMs(toolName: string | undefined): number {
return toolName && LONG_RUNNING_TOOL_IDS.has(toolName)
? TOOL_WATCHDOG_LONG_RUNNING_MS
: TOOL_WATCHDOG_DEFAULT_MS
}
class ToolExecutionTimeoutError extends Error {
constructor(toolName: string, timeoutMs: number) {
super(
`Tool '${toolName}' timed out after ${Math.round(timeoutMs / 1000)}s on the Sim executor and was abandoned.`
)
this.name = 'ToolExecutionTimeoutError'
}
}
/**
* Execute a tool with a hard settlement guarantee. If the handler neither
* resolves nor rejects within the tool's watchdog cap, throw a timeout error
* so the standard failure path (persist failed row, publish terminal
* confirmation, resume Go with an error result) runs and the chat never
* wedges behind a hung await. The losing promise keeps running detached; its
* eventual settlement is ignored.
*/
async function executeToolWithWatchdog(toolCall: ToolCallState, execContext: ExecutionContext) {
const timeoutMs = toolWatchdogTimeoutMs(toolCall.name)
// Thread the invoking subagent's channel id per call (execContext is shared
// across the whole turn, so the channel id can't live on it) — server tools
// use it to scope the workspace_file -> edit_content intent handoff.
const toolContext = toolCall.parentToolCallId
? { ...execContext, parentToolCallId: toolCall.parentToolCallId }
: execContext
const execution = executeTool(toolCall.name, toolCall.params || {}, toolContext)
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
execution,
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new ToolExecutionTimeoutError(toolCall.name, timeoutMs)),
timeoutMs
)
}),
])
} finally {
if (timer) clearTimeout(timer)
// Swallow the abandoned promise's eventual rejection so it can't surface
// as an unhandled rejection after a watchdog loss.
execution.catch(() => {})
}
}
/**
* Last-resort settlement for a tool whose promise never settled (a hang the
* per-tool watchdog could not see, e.g. in post-processing or persistence).
* Records a terminal error state + failed async row so the checkpoint loop
* can resume Go with an error result instead of waiting forever.
*/
export async function forceFailHungToolCall(
toolCallId: string,
context: StreamingContext,
message: string
): Promise<void> {
const toolCall = context.toolCalls.get(toolCallId)
if (!toolCall || toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) return
setTerminalToolCallState(toolCall, {
status: MothershipStreamV1ToolOutcome.error,
error: message,
})
logger.error('Force-failed hung tool call', {
toolCallId,
toolName: toolCall.name,
message,
})
markToolResultSeen(toolCallId)
await completeAsyncToolCall({
toolCallId,
status: MothershipStreamV1AsyncToolRecordStatus.failed,
result: { error: message },
error: message,
}).catch((err) => {
logger.warn('Failed to persist force-failed async tool status', {
toolCallId,
error: toError(err).message,
})
})
publishTerminalToolConfirmation({
toolCallId,
status: MothershipStreamV1ToolOutcome.error,
message,
data: { error: message },
})
}
function cancelledCompletion(message: string): AsyncToolCompletion {
return buildCompletionSignal({
status: MothershipStreamV1ToolOutcome.cancelled,
message,
data: { cancelled: true },
})
}
function terminalCompletionFromToolCall(toolCall: ToolCallState): AsyncToolCompletion {
if (toolCall.status === MothershipStreamV1ToolOutcome.cancelled) {
return cancelledCompletion(requireToolCallError(toolCall))
}
if (toolCall.status === MothershipStreamV1ToolOutcome.success) {
const data = getToolCallStateOutput(toolCall)
return buildCompletionSignal({
status: MothershipStreamV1ToolOutcome.success,
message: 'Tool completed',
...(data !== undefined ? { data } : {}),
})
}
if (toolCall.status === MothershipStreamV1ToolOutcome.skipped) {
const data = getToolCallStateOutput(toolCall)
return buildCompletionSignal({
status: MothershipStreamV1ToolOutcome.success,
message: 'Tool skipped',
...(data !== undefined ? { data } : {}),
})
}
const terminalErrorMessage = requireToolCallError(toolCall)
return buildCompletionSignal({
status: MothershipStreamV1ToolOutcome.error,
message: terminalErrorMessage,
data: getToolCallTerminalData(toolCall),
})
}
export async function executeToolAndReport(
toolCallId: string,
context: StreamingContext,
execContext: ExecutionContext,
options?: OrchestratorOptions
): Promise<AsyncToolCompletion> {
const toolCall = context.toolCalls.get(toolCallId)
if (!toolCall)
return buildCompletionSignal({
status: MothershipStreamV1ToolOutcome.error,
message: 'Tool call not found',
})
const argsPayload = toolCall.params
? (() => {
try {
return JSON.stringify(toolCall.params)
} catch {
return undefined
}
})()
: undefined
return withCopilotToolSpan(
{
toolName: toolCall.name,
toolCallId: toolCall.id,
runId: context.runId,
chatId: execContext.chatId,
argsBytes: argsPayload?.length,
argsPreview: argsPayload?.slice(0, 200),
},
async (otelSpan) => {
const startedAt = Date.now()
try {
const completion = await executeToolAndReportInner(toolCall, context, execContext, options)
const durationMs = Date.now() - startedAt
otelSpan.setAttribute(TraceAttr.ToolOutcome, completion.status)
otelSpan.setAttribute(TraceAttr.ToolDurationMs, durationMs)
if (completion.message) {
otelSpan.setAttribute(
TraceAttr.ToolOutcomeMessage,
String(completion.message).slice(0, 500)
)
}
// Durable Grafana signal for "which Sim tool is slowest" (executor=sim);
// pairs with the Go executor-boundary metric (U15) as one series set.
recordSimToolMetric(toolCall.name, completion.status, durationMs)
return completion
} catch (err) {
// executeToolAndReportInner threw (infra/unexpected error, not a normal
// 'error' completion). Still stamp the span + record the dispatch so
// copilot.tool.* isn't silently biased toward successful calls.
const durationMs = Date.now() - startedAt
otelSpan.setAttribute(TraceAttr.ToolOutcome, 'error')
otelSpan.setAttribute(TraceAttr.ToolDurationMs, durationMs)
recordSimToolMetric(toolCall.name, 'error', durationMs)
throw err
}
}
)
}
async function executeToolAndReportInner(
toolCall: ToolCallState,
context: StreamingContext,
execContext: ExecutionContext,
options?: OrchestratorOptions
): Promise<AsyncToolCompletion> {
if (toolCall.status === 'executing') {
return buildCompletionSignal({
status: MothershipStreamV1AsyncToolRecordStatus.running,
message: 'Tool already executing',
})
}
if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) {
return terminalCompletionFromToolCall(toolCall)
}
const markToolCallCancelled = (message: string) => {
setTerminalToolCallState(toolCall, {
status: MothershipStreamV1ToolOutcome.cancelled,
error: message,
})
}
if (abortRequested(context, execContext, options)) {
markToolCallCancelled('Request aborted before tool execution')
markToolResultSeen(toolCall.id)
await completeAsyncToolCall({
toolCallId: toolCall.id,
status: MothershipStreamV1AsyncToolRecordStatus.cancelled,
result: { cancelled: true },
error: 'Request aborted before tool execution',
}).catch((err) => {
logger.warn('Failed to persist async tool status', {
toolCallId: toolCall.id,
error: toError(err).message,
})
})
publishTerminalToolConfirmation({
toolCallId: toolCall.id,
status: MothershipStreamV1ToolOutcome.cancelled,
message: 'Request aborted before tool execution',
data: { cancelled: true },
})
return cancelledCompletion('Request aborted before tool execution')
}
toolCall.status = 'executing'
await upsertAsyncToolCall({
runId: context.runId,
toolCallId: toolCall.id,
toolName: toolCall.name,
args: toolCall.params,
}).catch((err) => {
logger.warn('Failed to persist async tool row before execution', {
toolCallId: toolCall.id,
error: toError(err).message,
})
})
await markAsyncToolRunning(toolCall.id, 'sim-stream').catch((err) => {
logger.warn('Failed to mark async tool running', {
toolCallId: toolCall.id,
error: toError(err).message,
})
})
if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) {
return terminalCompletionFromToolCall(toolCall)
}
const argsPreview = toolCall.params ? JSON.stringify(toolCall.params).slice(0, 200) : undefined
const toolSpan = context.trace.startSpan(toolCall.name, 'tool.execute', {
toolCallId: toolCall.id,
toolName: toolCall.name,
argsPreview,
abortSignalAborted: execContext.abortSignal?.aborted ?? false,
})
const endToolSpan = (
status: string,
detail?: { error?: string; cancelReason?: string; resultSuccess?: boolean }
) => {
const abortDetail: Record<string, unknown> = {}
if (execContext.abortSignal?.aborted) {
abortDetail.abortSignalAborted = true
abortDetail.abortReason = String(execContext.abortSignal.reason ?? 'unknown')
}
if (options?.abortSignal?.aborted) {
abortDetail.optionsAbortReason = String(options.abortSignal.reason ?? 'unknown')
}
if (context.wasAborted) {
abortDetail.wasAborted = true
}
toolSpan.attributes = { ...toolSpan.attributes, ...abortDetail, ...detail }
context.trace.endSpan(toolSpan, status)
}
const endToolSpanFromTerminalState = () => {
const terminalStatus =
toolCall.status === MothershipStreamV1ToolOutcome.cancelled
? 'cancelled'
: toolCall.status === MothershipStreamV1ToolOutcome.success ||
toolCall.status === MothershipStreamV1ToolOutcome.skipped
? 'ok'
: 'error'
endToolSpan(terminalStatus, {
resultSuccess: toolCall.status === MothershipStreamV1ToolOutcome.success,
...(toolCall.error ? { error: toolCall.error } : {}),
})
}
logger.info('Tool execution started', {
toolCallId: toolCall.id,
toolName: toolCall.name,
})
try {
ensureHandlersRegistered()
let result = await executeToolWithWatchdog(toolCall, execContext)
if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) {
endToolSpanFromTerminalState()
return terminalCompletionFromToolCall(toolCall)
}
if (abortRequested(context, execContext, options)) {
markToolCallCancelled('Request aborted during tool execution')
markToolResultSeen(toolCall.id)
await completeAsyncToolCall({
toolCallId: toolCall.id,
status: MothershipStreamV1AsyncToolRecordStatus.cancelled,
result: { cancelled: true },
error: 'Request aborted during tool execution',
}).catch((err) => {
logger.warn('Failed to persist async tool status', {
toolCallId: toolCall.id,
error: toError(err).message,
})
})
publishTerminalToolConfirmation({
toolCallId: toolCall.id,
status: MothershipStreamV1ToolOutcome.cancelled,
message: 'Request aborted during tool execution',
data: { cancelled: true },
})
endToolSpan('cancelled', {
cancelReason: 'abort_during_execution',
error: result.success === false ? result.error : undefined,
})
return cancelledCompletion('Request aborted during tool execution')
}
result = await maybeWriteOutputToFile(toolCall.name, toolCall.params, result, execContext)
if (abortRequested(context, execContext, options)) {
markToolCallCancelled('Request aborted during tool post-processing')
markToolResultSeen(toolCall.id)
await completeAsyncToolCall({
toolCallId: toolCall.id,
status: MothershipStreamV1AsyncToolRecordStatus.cancelled,
result: { cancelled: true },
error: 'Request aborted during tool post-processing',
}).catch((err) => {
logger.warn('Failed to persist async tool status', {
toolCallId: toolCall.id,
error: toError(err).message,
})
})
publishTerminalToolConfirmation({
toolCallId: toolCall.id,
status: MothershipStreamV1ToolOutcome.cancelled,
message: 'Request aborted during tool post-processing',
data: { cancelled: true },
})
endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_file' })
return cancelledCompletion('Request aborted during tool post-processing')
}
result = await maybeWriteOutputToTable(toolCall.name, toolCall.params, result, execContext)
if (abortRequested(context, execContext, options)) {
markToolCallCancelled('Request aborted during tool post-processing')
markToolResultSeen(toolCall.id)
await completeAsyncToolCall({
toolCallId: toolCall.id,
status: MothershipStreamV1AsyncToolRecordStatus.cancelled,
result: { cancelled: true },
error: 'Request aborted during tool post-processing',
}).catch((err) => {
logger.warn('Failed to persist async tool status', {
toolCallId: toolCall.id,
error: toError(err).message,
})
})
publishTerminalToolConfirmation({
toolCallId: toolCall.id,
status: MothershipStreamV1ToolOutcome.cancelled,
message: 'Request aborted during tool post-processing',
data: { cancelled: true },
})
endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_table' })
return cancelledCompletion('Request aborted during tool post-processing')
}
result = await maybeWriteReadCsvToTable(toolCall.name, toolCall.params, result, execContext)
if (abortRequested(context, execContext, options)) {
markToolCallCancelled('Request aborted during tool post-processing')
markToolResultSeen(toolCall.id)
await completeAsyncToolCall({
toolCallId: toolCall.id,
status: MothershipStreamV1AsyncToolRecordStatus.cancelled,
result: { cancelled: true },
error: 'Request aborted during tool post-processing',
}).catch((err) => {
logger.warn('Failed to persist async tool status', {
toolCallId: toolCall.id,
error: toError(err).message,
})
})
publishTerminalToolConfirmation({
toolCallId: toolCall.id,
status: MothershipStreamV1ToolOutcome.cancelled,
message: 'Request aborted during tool post-processing',
data: { cancelled: true },
})
endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_csv' })
return cancelledCompletion('Request aborted during tool post-processing')
}
toolSpan.attributes = {
...toolSpan.attributes,
...summarizeToolResultForSpan(result),
}
setTerminalToolCallState(toolCall, {
status: result.success
? MothershipStreamV1ToolOutcome.success
: MothershipStreamV1ToolOutcome.error,
...(hasOutputValue(result) ? { output: result.output } : {}),
...(result.success ? {} : { error: result.error || 'Tool failed' }),
})
if (result.success) {
const raw = result.output
const preview =
typeof raw === 'string'
? raw.slice(0, 200)
: raw && typeof raw === 'object'
? JSON.stringify(raw).slice(0, 200)
: undefined
logger.info('Tool execution succeeded', {
toolCallId: toolCall.id,
toolName: toolCall.name,
outputPreview: preview,
})
} else {
logger.warn('Tool execution failed', {
toolCallId: toolCall.id,
toolName: toolCall.name,
error: result.error,
params: toolCall.params,
})
}
// If create_workflow was successful, update the execution context with the new workflowId.
// This ensures subsequent tools in the same stream have access to the workflowId.
const createWorkflowOutput = getCreateWorkflowOutput(result.output)
if (
toolCall.name === CreateWorkflow.id &&
result.success &&
createWorkflowOutput?.workflowId &&
!execContext.workflowId
) {
execContext.workflowId = createWorkflowOutput.workflowId
if (createWorkflowOutput.workspaceId) {
execContext.workspaceId = createWorkflowOutput.workspaceId
}
}
const terminalStatus = result.success
? MothershipStreamV1ToolOutcome.success
: MothershipStreamV1ToolOutcome.error
const terminalMessage = result.success ? 'Tool completed' : requireToolCallError(toolCall)
const terminalData = getToolCallTerminalData(toolCall)
markToolResultSeen(toolCall.id)
await completeAsyncToolCall({
toolCallId: toolCall.id,
status: result.success
? MothershipStreamV1AsyncToolRecordStatus.completed
: MothershipStreamV1AsyncToolRecordStatus.failed,
...(terminalData !== undefined ? { result: terminalData } : {}),
error: result.success ? null : terminalMessage,
}).catch((err) => {
logger.warn('Failed to persist async tool completion', {
toolCallId: toolCall.id,
error: toError(err).message,
})
})
publishTerminalToolConfirmation({
toolCallId: toolCall.id,
status: terminalStatus,
message: terminalMessage,
...(terminalData !== undefined ? { data: terminalData } : {}),
})
if (abortRequested(context, execContext, options)) {
markToolCallCancelled('Request aborted before tool result delivery')
endToolSpan('cancelled', { cancelReason: 'abort_before_tool_result_delivery' })
return cancelledCompletion('Request aborted before tool result delivery')
}
// Fire-and-forget: notify the copilot backend that the tool completed.
// IMPORTANT: We must NOT await this — the Go backend may block on the
const resultEvent: StreamEvent = {
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: toolCall.id,
toolName: toolCall.name,
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.result,
success: result.success,
output: result.output,
...(result.success
? { status: MothershipStreamV1ToolOutcome.success }
: { status: MothershipStreamV1ToolOutcome.error }),
},
}
await options?.onEvent?.(resultEvent)
if (abortRequested(context, execContext, options)) {
markToolCallCancelled('Request aborted before resource persistence')
endToolSpan('cancelled', { cancelReason: 'abort_before_resource_persistence' })
return cancelledCompletion('Request aborted before resource persistence')
}
if (result.success && execContext.chatId && !abortRequested(context, execContext, options)) {
await handleResourceSideEffects(
toolCall.name,
toolCall.params,
result,
execContext.chatId,
options?.onEvent,
() => abortRequested(context, execContext, options)
)
}
endToolSpan(result.success ? 'ok' : 'error', {
resultSuccess: result.success,
...(result.success ? {} : { error: terminalMessage }),
})
return buildCompletionSignal({
status: terminalStatus,
message: terminalMessage,
...(terminalData !== undefined ? { data: terminalData } : {}),
})
} catch (error) {
const thrownMessage = toError(error).message
if (abortRequested(context, execContext, options)) {
markToolCallCancelled('Request aborted during tool execution')
markToolResultSeen(toolCall.id)
await completeAsyncToolCall({
toolCallId: toolCall.id,
status: MothershipStreamV1AsyncToolRecordStatus.cancelled,
result: { cancelled: true },
error: 'Request aborted during tool execution',
}).catch((err) => {
logger.warn('Failed to persist async tool status', {
toolCallId: toolCall.id,
error: toError(err).message,
})
})
publishTerminalToolConfirmation({
toolCallId: toolCall.id,
status: MothershipStreamV1ToolOutcome.cancelled,
message: 'Request aborted during tool execution',
data: { cancelled: true },
})
endToolSpan('cancelled', {
cancelReason: 'abort_during_execution_catch',
error: thrownMessage,
})
return cancelledCompletion('Request aborted during tool execution')
}
setTerminalToolCallState(toolCall, {
status: MothershipStreamV1ToolOutcome.error,
error: thrownMessage,
})
logger.error('Tool execution threw', {
toolCallId: toolCall.id,
toolName: toolCall.name,
error: toolCall.error,
params: toolCall.params,
})
markToolResultSeen(toolCall.id)
await completeAsyncToolCall({
toolCallId: toolCall.id,
status: MothershipStreamV1AsyncToolRecordStatus.failed,
result: { error: toolCall.error },
error: toolCall.error,
}).catch((err) => {
logger.warn('Failed to persist async tool error', {
toolCallId: toolCall.id,
error: toError(err).message,
})
})
publishTerminalToolConfirmation({
toolCallId: toolCall.id,
status: MothershipStreamV1ToolOutcome.error,
message: toolCall.error,
data: { error: toolCall.error },
})
const errorEvent: StreamEvent = {
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: toolCall.id,
toolName: toolCall.name,
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.result,
status: MothershipStreamV1ToolOutcome.error,
success: false,
error: toolCall.error,
output: { error: toolCall.error },
},
}
await options?.onEvent?.(errorEvent)
endToolSpan('error', { error: thrownMessage })
return buildCompletionSignal({
status: MothershipStreamV1ToolOutcome.error,
message: toolCall.error,
data: { error: toolCall.error },
})
}
}
@@ -0,0 +1,199 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockWriteWorkspaceFileByPath } = vi.hoisted(() => ({
mockWriteWorkspaceFileByPath: vi.fn(),
}))
vi.mock('@/lib/copilot/vfs/resource-writer', () => ({
writeWorkspaceFileByPath: mockWriteWorkspaceFileByPath,
}))
vi.mock('@/lib/copilot/request/otel', () => ({
withCopilotSpan: (
_name: string,
_attrs: Record<string, unknown> | undefined,
fn: (span: unknown) => Promise<unknown>
) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn(), addEvent: vi.fn() }),
}))
import { FunctionExecute } from '@/lib/copilot/generated/tool-catalog-v1'
import {
extractTabularData,
maybeWriteOutputToFile,
normalizeOutputWorkspaceFileName,
serializeOutputForFile,
unwrapFunctionExecuteOutput,
} from '@/lib/copilot/request/tools/files'
import type { ExecutionContext } from '@/lib/copilot/request/types'
describe('unwrapFunctionExecuteOutput', () => {
it('unwraps the function_execute envelope { result, stdout }', () => {
expect(unwrapFunctionExecuteOutput({ result: 'name,age\nAlice,30', stdout: '' })).toBe(
'name,age\nAlice,30'
)
})
it('passes through objects that do not have both result + stdout', () => {
const output = { data: { rows: [], totalCount: 0 } }
expect(unwrapFunctionExecuteOutput(output)).toBe(output)
})
it('passes through strings and arrays untouched', () => {
expect(unwrapFunctionExecuteOutput('hello')).toBe('hello')
const arr: unknown[] = [{ a: 1 }]
expect(unwrapFunctionExecuteOutput(arr)).toBe(arr)
})
})
describe('serializeOutputForFile (csv)', () => {
it('returns raw CSV text when function_execute result is already a CSV string', () => {
const output = {
result: 'name,age\nAlice,30\nBob,40',
stdout: '(2 rows)',
}
expect(serializeOutputForFile(output, 'csv')).toBe('name,age\nAlice,30\nBob,40')
})
it('converts a result array of objects into CSV', () => {
const output = {
result: [
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: 40 },
],
stdout: '',
}
expect(serializeOutputForFile(output, 'csv')).toBe('name,age\nAlice,30\nBob,40')
})
it('returns the raw string when the non-envelope output is already a CSV string', () => {
expect(serializeOutputForFile('a,b\n1,2', 'csv')).toBe('a,b\n1,2')
})
it('falls back to JSON.stringify when the payload is not tabular and not a string', () => {
const output = { result: { foo: 'bar' }, stdout: '' }
expect(serializeOutputForFile(output, 'csv')).toBe('{\n "foo": "bar"\n}')
})
})
describe('serializeOutputForFile (json / txt / md)', () => {
it('unwraps the envelope for json format so the file contains only result', () => {
const output = { result: { hello: 'world' }, stdout: 'log' }
expect(serializeOutputForFile(output, 'json')).toBe('{\n "hello": "world"\n}')
})
it('returns the string payload as-is for txt/md/html formats', () => {
const output = { result: '# Report\n\nHello', stdout: '' }
expect(serializeOutputForFile(output, 'md')).toBe('# Report\n\nHello')
expect(serializeOutputForFile(output, 'txt')).toBe('# Report\n\nHello')
expect(serializeOutputForFile(output, 'html')).toBe('# Report\n\nHello')
})
})
describe('normalizeOutputWorkspaceFileName', () => {
it('derives the leaf file name from workflow alias output paths', () => {
expect(normalizeOutputWorkspaceFileName('workflows/My%20Workflow/changelog.md')).toBe(
'changelog.md'
)
expect(
normalizeOutputWorkspaceFileName('workflows/My%20Workflow/.plans/phase%201/implementation.md')
).toBe('implementation.md')
})
it('still handles normal workspace file output paths', () => {
expect(normalizeOutputWorkspaceFileName('files/Reports/output.csv')).toBe('output.csv')
})
})
describe('maybeWriteOutputToFile', () => {
function buildContext(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
return {
userId: 'user-1',
workflowId: 'wf-1',
workspaceId: 'workspace-1',
userPermission: 'write',
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
mockWriteWorkspaceFileByPath.mockResolvedValue({
id: 'file-1',
name: 'report.csv',
vfsPath: 'files/report.csv',
mode: 'overwrite',
})
})
it('denies a read-only principal without writing the file', async () => {
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
{ outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } },
{ success: true, output: { result: 'name,age\nAlice,30', stdout: '' } },
buildContext({ userPermission: 'read' })
)
expect(result.success).toBe(false)
expect(result.error).toContain('requires write access')
expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled()
})
it('does not deny a read-only principal when no workspace write occurs (sandbox export active)', async () => {
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
{ outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } },
{ success: true, output: { result: { files: [{ path: 'report.csv' }] }, stdout: '' } },
buildContext({ userPermission: 'read' })
)
expect(result.success).toBe(true)
expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled()
})
it('writes the output file for a write principal', async () => {
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
{ outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } },
{ success: true, output: { result: 'name,age\nAlice,30', stdout: '' } },
buildContext()
)
expect(result.success).toBe(true)
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1)
})
})
describe('extractTabularData', () => {
it('extracts rows directly from an array input', () => {
expect(extractTabularData([{ a: 1 }, { a: 2 }])).toEqual([{ a: 1 }, { a: 2 }])
})
it('does NOT unwrap function_execute envelopes on its own (callers must pre-unwrap)', () => {
// Caller is responsible for unwrapping { result, stdout } envelopes first.
// Keeping that concern out of this function prevents a double unwrap when
// the user's payload itself happens to have matching keys.
expect(extractTabularData({ result: [{ a: 1 }], stdout: '' })).toBeNull()
})
it('extracts rows from the user_table query_rows shape', () => {
const rows = extractTabularData({
data: {
rows: [
{ id: 'row_1', data: { name: 'Alice' } },
{ id: 'row_2', data: { name: 'Bob' } },
],
totalCount: 2,
},
})
expect(rows).toEqual([{ name: 'Alice' }, { name: 'Bob' }])
})
it('returns null for non-tabular inputs', () => {
expect(extractTabularData('plain string')).toBeNull()
expect(extractTabularData(null)).toBeNull()
expect(extractTabularData({ foo: 'bar' })).toBeNull()
})
})
+345
View File
@@ -0,0 +1,345 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { FunctionExecute, UserTable } from '@/lib/copilot/generated/tool-catalog-v1'
import { CopilotOutputFileOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { withCopilotSpan } from '@/lib/copilot/request/otel'
import { denyOutputWriteWithoutWritePermission } from '@/lib/copilot/request/tools/permissions'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer'
const logger = createLogger('CopilotToolResultFiles')
export const OUTPUT_PATH_TOOLS: Set<string> = new Set([FunctionExecute.id, UserTable.id])
export type OutputFormat = 'json' | 'csv' | 'txt' | 'md' | 'html'
export const EXT_TO_FORMAT: Record<string, OutputFormat> = {
'.json': 'json',
'.csv': 'csv',
'.txt': 'txt',
'.md': 'md',
'.html': 'html',
}
export const FORMAT_TO_CONTENT_TYPE: Record<OutputFormat, string> = {
json: 'application/json',
csv: 'text/csv',
txt: 'text/plain',
md: 'text/markdown',
html: 'text/html',
}
/**
* Unwraps the `function_execute` response envelope `{ result, stdout }` so the
* rest of the serialization code works on the user's actual payload (a string,
* array, object, etc.) instead of JSON-stringifying the envelope itself.
*
* Only unwraps when both keys are present — that's the unique shape of
* `function_execute` (see `apps/sim/tools/function/types.ts` `CodeExecutionOutput`).
* `user_table` returns `{ data, message, success }` which is left alone.
*/
export function unwrapFunctionExecuteOutput(output: unknown): unknown {
if (!output || typeof output !== 'object' || Array.isArray(output)) return output
const obj = output as Record<string, unknown>
if ('result' in obj && 'stdout' in obj) {
return obj.result
}
return output
}
/**
* Try to pull a flat array of row-objects out of an already-unwrapped tool
* payload. Callers are responsible for stripping any `function_execute`
* envelope first (via {@link unwrapFunctionExecuteOutput}) — this function
* does not re-unwrap, so a user payload that coincidentally has `result` and
* `stdout` keys is not mistaken for another envelope.
*/
export function extractTabularData(output: unknown): Record<string, unknown>[] | null {
if (!output || typeof output !== 'object') return null
if (Array.isArray(output)) {
if (output.length > 0 && typeof output[0] === 'object' && output[0] !== null) {
return output as Record<string, unknown>[]
}
return null
}
const obj = output as Record<string, unknown>
// user_table query_rows shape: { data: { rows: [{ data: {...} }], totalCount } }
if (obj.data && typeof obj.data === 'object' && !Array.isArray(obj.data)) {
const data = obj.data as Record<string, unknown>
if (Array.isArray(data.rows) && data.rows.length > 0) {
const rows = data.rows as Record<string, unknown>[]
if (typeof rows[0].data === 'object' && rows[0].data !== null) {
return rows.map((r) => r.data as Record<string, unknown>)
}
return rows
}
}
return null
}
export function escapeCsvValue(value: unknown): string {
if (value === null || value === undefined) return ''
const str = typeof value === 'object' ? JSON.stringify(value) : String(value)
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
export function convertRowsToCsv(rows: Record<string, unknown>[]): string {
if (rows.length === 0) return ''
const headerSet = new Set<string>()
for (const row of rows) {
for (const key of Object.keys(row)) {
headerSet.add(key)
}
}
const headers = [...headerSet]
const lines = [headers.map(escapeCsvValue).join(',')]
for (const row of rows) {
lines.push(headers.map((h) => escapeCsvValue(row[h])).join(','))
}
return lines.join('\n')
}
export function normalizeOutputWorkspaceFileName(outputPath: string): string {
const segments = decodeVfsPathSegments(outputPath.trim().replace(/^\/+|\/+$/g, ''))
const fileName = segments.at(-1)
if (!fileName) {
throw new Error('Output path must include a file name')
}
return fileName
}
export function resolveOutputFormat(fileName: string, explicit?: string): OutputFormat {
if (explicit && explicit in FORMAT_TO_CONTENT_TYPE) return explicit as OutputFormat
const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase()
return EXT_TO_FORMAT[ext] ?? 'json'
}
export function serializeOutputForFile(output: unknown, format: OutputFormat): string {
const unwrapped = unwrapFunctionExecuteOutput(output)
if (typeof unwrapped === 'string') return unwrapped
if (format === 'csv') {
const rows = extractTabularData(unwrapped)
if (rows && rows.length > 0) {
return convertRowsToCsv(rows)
}
}
return JSON.stringify(unwrapped, null, 2)
}
export interface OutputFileDeclaration {
path: string
mode?: 'create' | 'overwrite'
format?: OutputFormat
mimeType?: string
sandboxPath?: string
formatPath?: string
}
export function getOutputFileDeclarations(
params: Record<string, unknown> | undefined
): OutputFileDeclaration[] {
const args = params?.args as Record<string, unknown> | undefined
const outputs =
(params?.outputs as { files?: unknown[] } | undefined) ??
(args?.outputs as { files?: unknown[] } | undefined)
if (Array.isArray(outputs?.files)) {
return outputs.files.flatMap((item): OutputFileDeclaration[] => {
if (!item || typeof item !== 'object') return []
const file = item as Record<string, unknown>
if (typeof file.path !== 'string') return []
return [
{
path: file.path,
mode: file.mode === 'overwrite' ? 'overwrite' : 'create',
format: typeof file.format === 'string' ? (file.format as OutputFormat) : undefined,
mimeType: typeof file.mimeType === 'string' ? file.mimeType : undefined,
sandboxPath: typeof file.sandboxPath === 'string' ? file.sandboxPath : undefined,
},
]
})
}
const outputPath =
(params?.outputPath as string | undefined) ?? (args?.outputPath as string | undefined)
if (!outputPath) return []
const overwriteFileId =
(params?.overwriteFileId as string | undefined) ?? (args?.overwriteFileId as string | undefined)
return [
{
path: overwriteFileId || outputPath,
mode: overwriteFileId ? 'overwrite' : 'create',
formatPath: outputPath,
format: ((params?.outputFormat as string | undefined) ??
(args?.outputFormat as string | undefined)) as OutputFormat | undefined,
mimeType:
(params?.outputMimeType as string | undefined) ??
(args?.outputMimeType as string | undefined),
sandboxPath:
(params?.outputSandboxPath as string | undefined) ??
(args?.outputSandboxPath as string | undefined),
},
]
}
export async function maybeWriteOutputToFile(
toolName: string,
params: Record<string, unknown> | undefined,
result: ToolCallResult,
context: ExecutionContext
): Promise<ToolCallResult> {
if (!result.success || !result.output) return result
if (!OUTPUT_PATH_TOOLS.has(toolName)) return result
if (!context.workspaceId || !context.userId) return result
const outputFiles = getOutputFileDeclarations(params).filter((file) => !file.sandboxPath)
if (outputFiles.length === 0) return result
const outputObject =
result.output && typeof result.output === 'object' && !Array.isArray(result.output)
? (result.output as Record<string, unknown>)
: undefined
const resultObject =
outputObject?.result &&
typeof outputObject.result === 'object' &&
!Array.isArray(outputObject.result)
? (outputObject.result as Record<string, unknown>)
: undefined
if (Array.isArray(resultObject?.files)) {
logger.warn('Skipping returned-value output write because sandbox export response is active', {
toolName,
outputCount: outputFiles.length,
})
return result
}
const denied = denyOutputWriteWithoutWritePermission(context)
if (denied) return denied
// Only span the actual write path (where we upload to storage). Fast
// no-op returns above don't need a span — they'd just pad the trace
// with empty work.
return withCopilotSpan(
TraceSpan.CopilotToolsWriteOutputFile,
{
[TraceAttr.ToolName]: toolName,
[TraceAttr.WorkspaceId]: context.workspaceId,
},
async (span) => {
try {
const writtenFiles = []
for (const outputFile of outputFiles) {
const fileName = normalizeOutputWorkspaceFileName(
outputFile.formatPath ?? outputFile.path
)
const format = resolveOutputFormat(fileName, outputFile.format)
const content = serializeOutputForFile(result.output, format)
const contentType = outputFile.mimeType || FORMAT_TO_CONTENT_TYPE[format]
const buffer = Buffer.from(content, 'utf-8')
if (context.abortSignal?.aborted) {
throw new Error('Request aborted before tool mutation could be applied')
}
const written = await writeWorkspaceFileByPath({
workspaceId: context.workspaceId!,
userId: context.userId!,
target: {
path: outputFile.path,
mode: outputFile.mode ?? 'create',
mimeType: outputFile.mimeType,
},
buffer,
inferredMimeType: contentType,
})
writtenFiles.push({
...written,
bytes: buffer.length,
format,
requestedPath: outputFile.path,
})
}
const firstWritten = writtenFiles[0]
span.setAttributes({
[TraceAttr.CopilotOutputFileId]: firstWritten.id,
[TraceAttr.CopilotOutputFileName]: firstWritten.name,
[TraceAttr.CopilotOutputFileFormat]: firstWritten.format,
[TraceAttr.CopilotOutputFilePath]: firstWritten.vfsPath,
[TraceAttr.CopilotOutputFileMode]: firstWritten.mode,
[TraceAttr.CopilotOutputFileBytes]: firstWritten.bytes,
[TraceAttr.CopilotOutputFileOutcome]: CopilotOutputFileOutcome.Uploaded,
})
logger.info('Tool output written to file', {
toolName,
outputCount: writtenFiles.length,
files: writtenFiles.map((file) => ({
fileId: file.id,
vfsPath: file.vfsPath,
size: file.bytes,
})),
})
return {
success: true,
output: {
message:
writtenFiles.length === 1
? `Output ${firstWritten.mode === 'overwrite' ? 'updated' : 'written'} at ${firstWritten.vfsPath} (${firstWritten.bytes} bytes)`
: `Output written to ${writtenFiles.length} files`,
files: writtenFiles.map((file) => ({
fileId: file.id,
fileName: file.name,
vfsPath: file.vfsPath,
size: file.bytes,
downloadUrl: file.downloadUrl,
})),
fileId: firstWritten.id,
fileName: firstWritten.name,
vfsPath: firstWritten.vfsPath,
size: firstWritten.bytes,
downloadUrl: firstWritten.downloadUrl,
},
resources: writtenFiles.map((file) => ({
type: 'file',
id: file.id,
title: file.name,
path: file.vfsPath,
})),
}
} catch (err) {
const message = toError(err).message
logger.warn('Failed to write tool output to file', {
toolName,
outputPaths: outputFiles.map((file) => file.path),
error: message,
})
span.setAttribute(TraceAttr.CopilotOutputFileOutcome, CopilotOutputFileOutcome.Failed)
span.addEvent(TraceEvent.CopilotOutputFileError, {
[TraceAttr.ErrorMessage]: message.slice(0, 500),
})
return {
success: false,
error: `Failed to write output file: ${message}`,
}
}
}
)
}
@@ -0,0 +1,28 @@
import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
/**
* Guards a post-tool output-redirection sink against read-only principals.
*
* `function_execute`, `user_table`, and `read` are read-allowed for execution
* (they don't mutate the workspace themselves), so the router's `WRITE_ACTIONS`
* gate in `tools/server/router.ts` lets read-only collaborators run them. But
* their output-redirection declarations (`outputs.files`, `outputTable`)
* durably persist to the workspace — creating/overwriting files and table rows.
* Those writes must satisfy the same write gate as the dedicated mutation tools.
*
* Returns a denial `ToolCallResult` when the caller lacks write access (so the
* agent surfaces the same `Permission denied` outcome it gets from `create_file`
* / `user_table` writes), or `null` when the write may proceed.
*/
export function denyOutputWriteWithoutWritePermission(
context: ExecutionContext
): ToolCallResult | null {
if (permissionSatisfies(context.userPermission as PermissionType | undefined, 'write')) {
return null
}
return {
success: false,
error: `Permission denied: writing tool output to the workspace requires write access. You have '${context.userPermission ?? 'none'}' permission.`,
}
}
@@ -0,0 +1,134 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import {
MothershipStreamV1EventType,
MothershipStreamV1ResourceOp,
} 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 type { StreamEvent, ToolCallResult } from '@/lib/copilot/request/types'
import {
extractDeletedResourcesFromToolResult,
extractResourcesFromToolResult,
hasDeleteCapability,
isResourceToolName,
persistChatResources,
removeChatResources,
} from '@/lib/copilot/resources/persistence'
const logger = createLogger('CopilotResourceEffects')
/**
* Persist and emit resource events after a successful tool execution.
*
* Handles both creation/upsert and deletion of chat resources depending on
* the tool's capabilities and output shape.
*/
export async function handleResourceSideEffects(
toolName: string,
params: Record<string, unknown> | undefined,
result: ToolCallResult,
chatId: string,
onEvent: ((event: StreamEvent) => void | Promise<void>) | undefined,
isAborted: () => boolean
): Promise<void> {
// Cheap early exit so we don't emit a span for tools that can never
// produce resources (most of them). The span only shows up for tools
// that might actually do resource work.
if (
!hasDeleteCapability(toolName) &&
!isResourceToolName(toolName) &&
!(result.resources && result.resources.length > 0)
) {
return
}
return withCopilotSpan(
TraceSpan.CopilotToolsHandleResourceSideEffects,
{
[TraceAttr.ToolName]: toolName,
[TraceAttr.ChatId]: chatId,
},
async (span) => {
let isDeleteOp = false
let removedCount = 0
let upsertedCount = 0
if (hasDeleteCapability(toolName)) {
const deleted = extractDeletedResourcesFromToolResult(toolName, params, result.output)
if (deleted.length > 0) {
isDeleteOp = true
removedCount = deleted.length
// Detached from the span lifecycle — the span ends before the
// DB call completes. That is intentional; we want the span to
// reflect the synchronous decision + event emission, not the
// best-effort persistence.
removeChatResources(chatId, deleted).catch((err) => {
logger.warn('Failed to remove chat resources after deletion', {
chatId,
error: toError(err).message,
})
})
for (const resource of deleted) {
if (isAborted()) break
await onEvent?.({
type: MothershipStreamV1EventType.resource,
payload: {
op: MothershipStreamV1ResourceOp.remove,
resource: { type: resource.type, id: resource.id, title: resource.title },
},
})
}
}
}
if (!isDeleteOp && !isAborted()) {
const resources =
result.resources && result.resources.length > 0
? result.resources
: isResourceToolName(toolName)
? extractResourcesFromToolResult(toolName, params, result.output)
: []
if (resources.length > 0) {
upsertedCount = resources.length
logger.info('[file-stream-server] Emitting resource upsert events', {
toolName,
chatId,
resources: resources.map((r) => ({ type: r.type, id: r.id, title: r.title })),
})
persistChatResources(chatId, resources).catch((err) => {
logger.warn('Failed to persist chat resources', {
chatId,
error: toError(err).message,
})
})
for (const resource of resources) {
if (isAborted()) break
await onEvent?.({
type: MothershipStreamV1EventType.resource,
payload: {
op: MothershipStreamV1ResourceOp.upsert,
resource: { type: resource.type, id: resource.id, title: resource.title },
},
})
}
}
}
span.setAttributes({
[TraceAttr.CopilotResourcesOp]: isDeleteOp
? 'delete'
: upsertedCount > 0
? 'upsert'
: 'none',
[TraceAttr.CopilotResourcesRemovedCount]: removedCount,
[TraceAttr.CopilotResourcesUpsertedCount]: upsertedCount,
[TraceAttr.CopilotResourcesAborted]: isAborted(),
})
}
)
}
@@ -0,0 +1,253 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TableDefinition } from '@/lib/table'
const { mockGetTableById, mockReplaceTableRows } = vi.hoisted(() => ({
mockGetTableById: vi.fn(),
mockReplaceTableRows: vi.fn(),
}))
vi.mock('@/lib/table/service', () => ({
getTableById: mockGetTableById,
}))
vi.mock('@/lib/table/rows/service', () => ({
replaceTableRows: mockReplaceTableRows,
}))
vi.mock('@/lib/copilot/request/otel', () => ({
withCopilotSpan: (
_name: string,
_attrs: Record<string, unknown> | undefined,
fn: (span: unknown) => Promise<unknown>
) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn(), addEvent: vi.fn() }),
}))
import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1'
import {
maybeWriteOutputToTable,
maybeWriteReadCsvToTable,
} from '@/lib/copilot/request/tools/tables'
import type { ExecutionContext } from '@/lib/copilot/request/types'
function buildTable(overrides: Partial<TableDefinition> = {}): TableDefinition {
return {
id: 'tbl_1',
name: 'People',
description: null,
schema: {
columns: [
{ id: 'col_name', name: 'name', type: 'string' },
{ id: 'col_age', name: 'age', type: 'number' },
],
},
metadata: null,
rowCount: 0,
maxRows: 100,
workspaceId: 'workspace-1',
createdBy: 'user-1',
archivedAt: null,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
...overrides,
} as TableDefinition
}
function buildContext(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
return {
userId: 'user-1',
workflowId: 'wf-1',
workspaceId: 'workspace-1',
userPermission: 'write',
...overrides,
}
}
describe('maybeWriteOutputToTable', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetTableById.mockResolvedValue(buildTable())
mockReplaceTableRows.mockResolvedValue({ deletedCount: 0, insertedCount: 2 })
})
it('rejects a table from another workspace without touching it', async () => {
mockGetTableById.mockResolvedValue(buildTable({ workspaceId: 'other-workspace' }))
const result = await maybeWriteOutputToTable(
FunctionExecute.id,
{ outputTable: 'tbl_1' },
{ success: true, output: { result: [{ name: 'Alice' }] } },
buildContext()
)
expect(result).toEqual({ success: false, error: 'Table "tbl_1" not found' })
expect(mockReplaceTableRows).not.toHaveBeenCalled()
})
it('denies a read-only principal without touching the table', async () => {
const result = await maybeWriteOutputToTable(
FunctionExecute.id,
{ outputTable: 'tbl_1' },
{ success: true, output: { result: [{ name: 'Alice' }] } },
buildContext({ userPermission: 'read' })
)
expect(result.success).toBe(false)
expect(result.error).toContain('requires write access')
expect(mockGetTableById).not.toHaveBeenCalled()
expect(mockReplaceTableRows).not.toHaveBeenCalled()
})
it('replaces rows through the service with name keys remapped to column ids', async () => {
const result = await maybeWriteOutputToTable(
FunctionExecute.id,
{ outputTable: 'tbl_1' },
{
success: true,
output: {
result: [
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: 40 },
],
},
},
buildContext()
)
expect(result.success).toBe(true)
expect(mockReplaceTableRows).toHaveBeenCalledTimes(1)
const [data, table] = mockReplaceTableRows.mock.calls[0]
expect(data).toMatchObject({
tableId: 'tbl_1',
workspaceId: 'workspace-1',
userId: 'user-1',
rows: [
{ col_name: 'Alice', col_age: 30 },
{ col_name: 'Bob', col_age: 40 },
],
})
expect(table.id).toBe('tbl_1')
})
it('fails fast when no row keys match the table columns', async () => {
const result = await maybeWriteOutputToTable(
FunctionExecute.id,
{ outputTable: 'tbl_1' },
{ success: true, output: { result: [{ wrong: 1 }, { keys: 2 }] } },
buildContext()
)
expect(result.success).toBe(false)
expect(result.error).toContain('Row 1 has no keys matching columns')
expect(mockReplaceTableRows).not.toHaveBeenCalled()
})
it('fails fast when only some rows match instead of writing empty rows', async () => {
const result = await maybeWriteOutputToTable(
FunctionExecute.id,
{ outputTable: 'tbl_1' },
{ success: true, output: { result: [{ name: 'Alice' }, { wrong: 'x' }] } },
buildContext()
)
expect(result.success).toBe(false)
expect(result.error).toContain('Row 2 has no keys matching columns')
expect(mockReplaceTableRows).not.toHaveBeenCalled()
})
it('surfaces service validation failures as tool errors', async () => {
mockReplaceTableRows.mockRejectedValue(new Error('Row 1: name is required'))
const result = await maybeWriteOutputToTable(
FunctionExecute.id,
{ outputTable: 'tbl_1' },
{ success: true, output: { result: [{ age: 30 }] } },
buildContext()
)
expect(result.success).toBe(false)
expect(result.error).toContain('Row 1: name is required')
})
})
describe('maybeWriteReadCsvToTable', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetTableById.mockResolvedValue(buildTable())
mockReplaceTableRows.mockResolvedValue({ deletedCount: 0, insertedCount: 2 })
})
it('rejects a table from another workspace without touching it', async () => {
mockGetTableById.mockResolvedValue(buildTable({ workspaceId: 'other-workspace' }))
const result = await maybeWriteReadCsvToTable(
ReadTool.id,
{ outputTable: 'tbl_1', path: 'files/people.csv' },
{ success: true, output: { content: 'name,age\nAlice,30' } },
buildContext()
)
expect(result).toEqual({ success: false, error: 'Table "tbl_1" not found' })
expect(mockReplaceTableRows).not.toHaveBeenCalled()
})
it('denies a read-only principal without touching the table', async () => {
const result = await maybeWriteReadCsvToTable(
ReadTool.id,
{ outputTable: 'tbl_1', path: 'files/people.csv' },
{ success: true, output: { content: 'name,age\nAlice,30' } },
buildContext({ userPermission: 'read' })
)
expect(result.success).toBe(false)
expect(result.error).toContain('requires write access')
expect(mockGetTableById).not.toHaveBeenCalled()
expect(mockReplaceTableRows).not.toHaveBeenCalled()
})
it('imports CSV content through the service with id-keyed rows', async () => {
const result = await maybeWriteReadCsvToTable(
ReadTool.id,
{ outputTable: 'tbl_1', path: 'files/people.csv' },
{ success: true, output: { content: 'name,age\nAlice,30\nBob,40' } },
buildContext()
)
expect(result.success).toBe(true)
const [data] = mockReplaceTableRows.mock.calls[0]
expect(data.rows).toEqual([
{ col_name: 'Alice', col_age: '30' },
{ col_name: 'Bob', col_age: '40' },
])
})
it('fails fast when the file headers match no table columns', async () => {
const result = await maybeWriteReadCsvToTable(
ReadTool.id,
{ outputTable: 'tbl_1', path: 'files/people.csv' },
{ success: true, output: { content: 'wrong,headers\n1,2' } },
buildContext()
)
expect(result.success).toBe(false)
expect(result.error).toContain('Row 1 has no keys matching columns')
expect(mockReplaceTableRows).not.toHaveBeenCalled()
})
it('surfaces service validation failures as tool errors', async () => {
mockReplaceTableRows.mockRejectedValue(new Error('Row 1: name is required'))
const result = await maybeWriteReadCsvToTable(
ReadTool.id,
{ outputTable: 'tbl_1', path: 'files/people.csv' },
{ success: true, output: { content: 'age\n30' } },
buildContext()
)
expect(result.success).toBe(false)
expect(result.error).toContain('Row 1: name is required')
})
})
@@ -0,0 +1,300 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { parse as csvParse } from 'csv-parse/sync'
import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1'
import { CopilotTableOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { withCopilotSpan } from '@/lib/copilot/request/otel'
import { denyOutputWriteWithoutWritePermission } from '@/lib/copilot/request/tools/permissions'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
import type { RowData, TableDefinition } from '@/lib/table'
import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys'
import { replaceTableRows } from '@/lib/table/rows/service'
import { getTableById } from '@/lib/table/service'
const logger = createLogger('CopilotToolResultTables')
const MAX_OUTPUT_TABLE_ROWS = 10_000
/**
* Replaces a table's rows with wire rows keyed by column name. Translates the
* keys to stable column ids (unknown keys are dropped, matching every other
* name-translating boundary) and delegates to `replaceTableRows`, which owns
* locking, validation, plan row limits, batching, and rowCount maintenance.
*/
async function replaceTableRowsFromWire(
table: TableDefinition,
rows: Array<Record<string, unknown>>,
context: ExecutionContext
): Promise<{ error?: string }> {
const idByName = buildIdByName(table.schema)
const idKeyedRows = rows.map((row) => rowDataNameToId(row as RowData, idByName))
const emptyIndex = idKeyedRows.findIndex((row) => Object.keys(row).length === 0)
if (emptyIndex !== -1) {
return {
error: `Row ${emptyIndex + 1} has no keys matching columns on table "${table.name}" (columns: ${table.schema.columns.map((c) => c.name).join(', ')})`,
}
}
await replaceTableRows(
{
tableId: table.id,
rows: idKeyedRows,
workspaceId: table.workspaceId,
userId: context.userId,
},
table,
generateId().slice(0, 8)
)
return {}
}
export async function maybeWriteOutputToTable(
toolName: string,
params: Record<string, unknown> | undefined,
result: ToolCallResult,
context: ExecutionContext
): Promise<ToolCallResult> {
if (toolName !== FunctionExecute.id) return result
if (!result.success || !result.output) return result
if (!context.workspaceId || !context.userId) return result
const outputTable = params?.outputTable as string | undefined
if (!outputTable) return result
const denied = denyOutputWriteWithoutWritePermission(context)
if (denied) return denied
return withCopilotSpan(
TraceSpan.CopilotToolsWriteOutputTable,
{
[TraceAttr.ToolName]: toolName,
[TraceAttr.CopilotTableId]: outputTable,
[TraceAttr.WorkspaceId]: context.workspaceId,
},
async (span) => {
try {
const table = await getTableById(outputTable)
if (!table || table.workspaceId !== context.workspaceId) {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.TableNotFound)
return {
success: false,
error: `Table "${outputTable}" not found`,
}
}
const rawOutput = result.output
let rows: Array<Record<string, unknown>>
if (rawOutput && typeof rawOutput === 'object' && 'result' in rawOutput) {
const inner = (rawOutput as Record<string, unknown>).result
if (Array.isArray(inner)) {
rows = inner
} else {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape)
return {
success: false,
error: 'outputTable requires the code to return an array of objects',
}
}
} else if (Array.isArray(rawOutput)) {
rows = rawOutput
} else {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape)
return {
success: false,
error: 'outputTable requires the code to return an array of objects',
}
}
span.setAttribute(TraceAttr.CopilotTableRowCount, rows.length)
if (rows.length > MAX_OUTPUT_TABLE_ROWS) {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.RowLimitExceeded)
return {
success: false,
error: `outputTable row limit exceeded: got ${rows.length}, max is ${MAX_OUTPUT_TABLE_ROWS}`,
}
}
if (rows.length === 0) {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.EmptyRows)
return {
success: false,
error: 'outputTable requires at least one row — code returned an empty array',
}
}
if (context.abortSignal?.aborted) {
throw new Error('Request aborted before tool mutation could be applied')
}
const replaceResult = await replaceTableRowsFromWire(table, rows, context)
if (replaceResult.error) {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape)
return { success: false, error: replaceResult.error }
}
logger.info('Tool output written to table', {
toolName,
tableId: outputTable,
rowCount: rows.length,
})
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Wrote)
return {
success: true,
output: {
message: `Wrote ${rows.length} rows to table ${outputTable}`,
tableId: outputTable,
rowCount: rows.length,
},
}
} catch (err) {
logger.warn('Failed to write tool output to table', {
toolName,
outputTable,
error: toError(err).message,
})
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Failed)
span.addEvent(TraceEvent.CopilotTableError, {
[TraceAttr.ErrorMessage]: toError(err).message.slice(0, 500),
})
return {
success: false,
error: `Failed to write to table: ${toError(err).message}`,
}
}
}
)
}
export async function maybeWriteReadCsvToTable(
toolName: string,
params: Record<string, unknown> | undefined,
result: ToolCallResult,
context: ExecutionContext
): Promise<ToolCallResult> {
if (toolName !== ReadTool.id) return result
if (!result.success || !result.output) return result
if (!context.workspaceId || !context.userId) return result
const outputTable = params?.outputTable as string | undefined
if (!outputTable) return result
const denied = denyOutputWriteWithoutWritePermission(context)
if (denied) return denied
return withCopilotSpan(
TraceSpan.CopilotToolsWriteCsvToTable,
{
[TraceAttr.ToolName]: toolName,
[TraceAttr.CopilotTableId]: outputTable,
[TraceAttr.WorkspaceId]: context.workspaceId,
},
async (span) => {
try {
const table = await getTableById(outputTable)
if (!table || table.workspaceId !== context.workspaceId) {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.TableNotFound)
return { success: false, error: `Table "${outputTable}" not found` }
}
const output = result.output as Record<string, unknown>
const content = (output.content as string) || ''
if (!content.trim()) {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.EmptyContent)
return { success: false, error: 'File has no content to import into table' }
}
const filePath = (params?.path as string) || ''
const ext = filePath.split('.').pop()?.toLowerCase()
span.setAttributes({
[TraceAttr.CopilotTableSourcePath]: filePath,
[TraceAttr.CopilotTableSourceFormat]: ext === 'json' ? 'json' : 'csv',
[TraceAttr.CopilotTableSourceContentBytes]: content.length,
})
let rows: Record<string, unknown>[]
if (ext === 'json') {
const parsed = JSON.parse(content)
if (!Array.isArray(parsed)) {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidJsonShape)
return {
success: false,
error: 'JSON file must contain an array of objects for table import',
}
}
rows = parsed
} else {
rows = csvParse(content, {
columns: true,
skip_empty_lines: true,
trim: true,
relax_column_count: true,
relax_quotes: true,
skip_records_with_error: true,
cast: false,
}) as Record<string, unknown>[]
}
span.setAttribute(TraceAttr.CopilotTableRowCount, rows.length)
if (rows.length === 0) {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.EmptyRows)
return { success: false, error: 'File has no data rows to import' }
}
if (rows.length > MAX_OUTPUT_TABLE_ROWS) {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.RowLimitExceeded)
return {
success: false,
error: `Row limit exceeded: got ${rows.length}, max is ${MAX_OUTPUT_TABLE_ROWS}`,
}
}
if (context.abortSignal?.aborted) {
throw new Error('Request aborted before tool mutation could be applied')
}
const replaceResult = await replaceTableRowsFromWire(table, rows, context)
if (replaceResult.error) {
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape)
return { success: false, error: replaceResult.error }
}
logger.info('Read output written to table', {
toolName,
tableId: outputTable,
tableName: table.name,
rowCount: rows.length,
filePath,
})
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Imported)
return {
success: true,
output: {
message: `Imported ${rows.length} rows from "${filePath}" into table "${table.name}"`,
tableId: outputTable,
tableName: table.name,
rowCount: rows.length,
},
}
} catch (err) {
logger.warn('Failed to write read output to table', {
toolName,
outputTable,
error: toError(err).message,
})
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Failed)
span.addEvent(TraceEvent.CopilotTableError, {
[TraceAttr.ErrorMessage]: toError(err).message.slice(0, 500),
})
return {
success: false,
error: `Failed to import into table: ${toError(err).message}`,
}
}
}
)
}