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

283 lines
11 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import {
assertServerToolNotAborted,
type BaseServerTool,
type ServerToolContext,
} from '@/lib/copilot/tools/server/base-tool'
import { isE2BDocEnabled } from '@/lib/core/config/env-flags'
import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { getE2BDocFormat } from './doc-compile'
import { buildEmbeddedImageRefWarning } from './embedded-image-refs'
import { consumeLatestFileIntent } from './file-intent-store'
import { compileDocForWrite, getDocumentFormatInfo, inferContentType } from './workspace-file'
const logger = createLogger('EditContentServerTool')
type EditContentArgs = {
content: string
}
type EditContentResult = {
success: boolean
message: string
data?: Record<string, unknown>
}
export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentResult> = {
name: 'edit_content',
async execute(params: EditContentArgs, context?: ServerToolContext): Promise<EditContentResult> {
if (!context?.userId) {
logger.error('Unauthorized attempt to use edit_content')
throw new Error('Authentication required')
}
const workspaceId = context.workspaceId
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const raw = params as Record<string, unknown>
const nested = raw.args as Record<string, unknown> | undefined
const content =
typeof params.content === 'string'
? params.content
: typeof nested?.content === 'string'
? (nested.content as string)
: undefined
if (content === undefined) {
return { success: false, message: 'content is required for edit_content' }
}
// Consume the intent from THIS file subagent's channel (its outer tool_use
// id), not just the latest in the message — otherwise two file agents
// writing concurrently would each grab whichever workspace_file landed last
// and write their content into the wrong file. Falls back to latest-in-
// message when no channel id is present (main-agent / legacy calls).
const intent = await consumeLatestFileIntent(workspaceId, {
chatId: context.chatId,
messageId: context.messageId,
channelId: context.parentToolCallId,
})
if (!intent) {
return {
success: false,
message:
'No workspace_file context found. Call workspace_file first, wait for it to succeed, then call edit_content in the next step. Do not emit edit_content in parallel or in the same batch as workspace_file.',
}
}
try {
const { operation, fileRecord } = intent
const docInfo = getDocumentFormatInfo(fileRecord.name)
const e2bFmt = isE2BDocEnabled ? await getE2BDocFormat(fileRecord.name) : null
let finalContent: string
switch (operation) {
case 'append': {
const existing = intent.existingContent ?? ''
// The JS engines (isolated-vm and E2B-node pptx/docx) use the `{ ... }`
// block-append convention — block statements scope cleanly inside the
// compile wrapper. Python docs (pdf/xlsx) are a single cohesive script,
// so brace-wrapping would produce invalid Python; plain-concatenate.
// Brace-wrap appended content for the JS engines (isolated-vm and
// E2B-node pptx/docx); Python docs (pdf/xlsx) are one cohesive script.
const braceWrap = e2bFmt ? e2bFmt.engine === 'node' : docInfo.isDoc
finalContent = braceWrap
? existing
? `${existing}\n{\n${content}\n}`
: content
: existing
? `${existing}\n${content}`
: content
break
}
case 'update': {
finalContent = content
break
}
case 'patch': {
const existing = intent.existingContent ?? ''
if (!intent.edit) {
return { success: false, message: 'Patch intent missing edit metadata' }
}
if (intent.edit.strategy === 'search_replace') {
const search = intent.edit.search!
const firstIdx = existing.indexOf(search)
if (firstIdx === -1) {
return {
success: false,
message: `Patch failed: search string not found in file "${fileRecord.name}"`,
}
}
finalContent = intent.edit.replaceAll
? existing.split(search).join(content)
: existing.slice(0, firstIdx) + content + existing.slice(firstIdx + search.length)
} else if (intent.edit.strategy === 'anchored') {
const lines = existing.split('\n')
const defaultOccurrence = intent.edit.occurrence ?? 1
const findAnchorLine = (
anchor: string,
occurrence = defaultOccurrence,
afterIndex = -1
): { index: number; error?: string } => {
const trimmed = anchor.trim()
let count = 0
for (let i = afterIndex + 1; i < lines.length; i++) {
if (lines[i].trim() === trimmed) {
count++
if (count === occurrence) return { index: i }
}
}
if (count === 0) {
return {
index: -1,
error: `Anchor line not found in "${fileRecord.name}": "${anchor.slice(0, 100)}"`,
}
}
return {
index: -1,
error: `Anchor line occurrence ${occurrence} not found (only ${count} match${count > 1 ? 'es' : ''}) in "${fileRecord.name}": "${anchor.slice(0, 100)}"`,
}
}
if (intent.edit.mode === 'replace_between') {
if (!intent.edit.before_anchor || !intent.edit.after_anchor) {
return {
success: false,
message: 'replace_between requires before_anchor and after_anchor',
}
}
const before = findAnchorLine(intent.edit.before_anchor)
if (before.error) return { success: false, message: `Patch failed: ${before.error}` }
const after = findAnchorLine(
intent.edit.after_anchor,
defaultOccurrence,
before.index
)
if (after.error) return { success: false, message: `Patch failed: ${after.error}` }
if (after.index <= before.index) {
return {
success: false,
message: 'Patch failed: after_anchor must appear after before_anchor in the file',
}
}
const newLines = [
...lines.slice(0, before.index + 1),
...content.split('\n'),
...lines.slice(after.index),
]
finalContent = newLines.join('\n')
} else if (intent.edit.mode === 'insert_after') {
if (!intent.edit.anchor) {
return { success: false, message: 'insert_after requires anchor' }
}
const found = findAnchorLine(intent.edit.anchor)
if (found.error) return { success: false, message: `Patch failed: ${found.error}` }
const newLines = [
...lines.slice(0, found.index + 1),
...content.split('\n'),
...lines.slice(found.index + 1),
]
finalContent = newLines.join('\n')
} else if (intent.edit.mode === 'delete_between') {
if (!intent.edit.start_anchor || !intent.edit.end_anchor) {
return {
success: false,
message: 'delete_between requires start_anchor and end_anchor',
}
}
const start = findAnchorLine(intent.edit.start_anchor)
if (start.error) return { success: false, message: `Patch failed: ${start.error}` }
const end = findAnchorLine(intent.edit.end_anchor, defaultOccurrence, start.index)
if (end.error) return { success: false, message: `Patch failed: ${end.error}` }
if (end.index <= start.index) {
return {
success: false,
message: 'Patch failed: end_anchor must appear after start_anchor in the file',
}
}
const newLines = [...lines.slice(0, start.index), ...lines.slice(end.index)]
finalContent = newLines.join('\n')
} else {
return {
success: false,
message: `Unknown anchored patch mode: "${intent.edit.mode}"`,
}
}
} else {
return { success: false, message: `Unknown patch strategy: "${intent.edit.strategy}"` }
}
break
}
default:
return { success: false, message: `Unsupported operation in intent: ${operation}` }
}
// Compile once via the right engine (or isolated-vm fallback) and resolve
// the source MIME to store. Shared with the create path.
const compiled = await compileDocForWrite({
source: finalContent,
fileName: fileRecord.name,
workspaceId,
ownerKey: `user:${context.userId}`,
signal: context.abortSignal,
fallbackMime: inferContentType(fileRecord.name, intent.contentType),
})
if (!compiled.ok) {
return { success: false, message: compiled.message }
}
const fileBuffer = Buffer.from(finalContent, 'utf-8')
assertServerToolNotAborted(context)
await updateWorkspaceFileContent(
workspaceId,
intent.fileId,
context.userId,
fileBuffer,
compiled.sourceMime
)
const verb =
operation === 'append' ? 'appended to' : operation === 'update' ? 'updated' : 'patched'
logger.info(`Workspace file ${verb} via copilot (edit_content)`, {
fileId: intent.fileId,
name: fileRecord.name,
operation,
size: fileBuffer.length,
userId: context.userId,
})
// Flag any `/api/files/view/<id>` embeds the model just authored that won't render/export
// (non-workspace or missing), so it can self-correct on the next step.
const embedWarning = await buildEmbeddedImageRefWarning(content, workspaceId)
return {
success: true,
message: `File "${fileRecord.name}" ${verb} successfully (${fileBuffer.length} bytes)${embedWarning}`,
data: {
id: intent.fileId,
name: fileRecord.name,
size: fileBuffer.length,
contentType: compiled.sourceMime,
},
}
} catch (error) {
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
logger.error('Error in edit_content tool', {
operation: intent.operation,
fileId: intent.fileId,
error: errorMessage,
userId: context.userId,
})
return {
success: false,
message: `Failed to apply content: ${errorMessage}`,
}
}
},
}