Files
simstudioai--sim/apps/sim/lib/copilot/request/tools/files.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

346 lines
12 KiB
TypeScript

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}`,
}
}
}
)
}