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
283 lines
11 KiB
TypeScript
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}`,
|
|
}
|
|
}
|
|
},
|
|
}
|