From b49d67e46c4f3d8304ef98247ad3ee8fc58cb64e Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 8 Apr 2026 18:24:22 -0700 Subject: [PATCH] Remove tool truncation limits --- apps/sim/lib/copilot/constants.ts | 14 +++ .../tools/client/tool-display-registry.ts | 67 ----------- .../lib/copilot/tools/handlers/vfs.test.ts | 89 +++++++++++++++ apps/sim/lib/copilot/tools/handlers/vfs.ts | 56 +++++++++- .../tools/server/other/make-api-request.ts | 105 ------------------ apps/sim/lib/copilot/tools/server/router.ts | 2 - 6 files changed, 158 insertions(+), 175 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/handlers/vfs.test.ts delete mode 100644 apps/sim/lib/copilot/tools/server/other/make-api-request.ts diff --git a/apps/sim/lib/copilot/constants.ts b/apps/sim/lib/copilot/constants.ts index 8aba3b0256..d0d10f452c 100644 --- a/apps/sim/lib/copilot/constants.ts +++ b/apps/sim/lib/copilot/constants.ts @@ -50,6 +50,20 @@ export const COPILOT_STATS_API_PATH = '/api/copilot/stats' /** Maximum entries in the in-memory SSE tool-event dedup cache. */ export const STREAM_BUFFER_MAX_DEDUP_ENTRIES = 1_000 +// --------------------------------------------------------------------------- +// Tool result size limits +// --------------------------------------------------------------------------- + +/** Approximate max inline tool-result budget before artifact/error handling takes over. */ +export const TOOL_RESULT_MAX_INLINE_TOKENS = 50_000 + +/** Rough chars-per-token estimate used when only serialized text length is available. */ +export const TOOL_RESULT_ESTIMATED_CHARS_PER_TOKEN = 4 + +/** Approximate max inline tool-result size in characters. */ +export const TOOL_RESULT_MAX_INLINE_CHARS = + TOOL_RESULT_MAX_INLINE_TOKENS * TOOL_RESULT_ESTIMATED_CHARS_PER_TOKEN + // --------------------------------------------------------------------------- // Copilot modes // --------------------------------------------------------------------------- diff --git a/apps/sim/lib/copilot/tools/client/tool-display-registry.ts b/apps/sim/lib/copilot/tools/client/tool-display-registry.ts index f9756090e7..29a600657b 100644 --- a/apps/sim/lib/copilot/tools/client/tool-display-registry.ts +++ b/apps/sim/lib/copilot/tools/client/tool-display-registry.ts @@ -11,7 +11,6 @@ import { FolderPlus, GitBranch, Globe, - Globe2, Grid2x2, Grid2x2Check, Grid2x2X, @@ -960,71 +959,6 @@ const META_list_workspace_mcp_servers: ToolMetadata = { interrupt: undefined, } -const META_make_api_request: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Preparing API request', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Review API request', icon: Globe2 }, - [ClientToolCallState.executing]: { text: 'Executing API request', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Completed API request', icon: Globe2 }, - [ClientToolCallState.error]: { text: 'Failed to execute API request', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped API request', icon: MinusCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted API request', icon: XCircle }, - }, - interrupt: { - accept: { text: 'Execute', icon: Globe2 }, - reject: { text: 'Skip', icon: MinusCircle }, - }, - uiConfig: { - interrupt: { - accept: { text: 'Execute', icon: Globe2 }, - reject: { text: 'Skip', icon: MinusCircle }, - showAllowOnce: true, - showAllowAlways: true, - }, - paramsTable: { - columns: [ - { key: 'method', label: 'Method', width: '26%', editable: true, mono: true }, - { key: 'url', label: 'Endpoint', width: '74%', editable: true, mono: true }, - ], - extractRows: (params: Record): Array<[string, ...any[]]> => { - return [['request', (params.method || 'GET').toUpperCase(), params.url || '']] - }, - }, - }, - getDynamicText: (params, state) => { - if (params?.url && typeof params.url === 'string') { - const method = params.method || 'GET' - let url = params.url - - // Extract domain from URL for cleaner display - try { - const urlObj = new URL(url) - url = urlObj.hostname + urlObj.pathname - } catch { - // Use URL as-is if parsing fails - } - - switch (state) { - case ClientToolCallState.success: - return `${method} ${url} complete` - case ClientToolCallState.executing: - return `${method} ${url}` - case ClientToolCallState.generating: - return `Preparing ${method} ${url}` - case ClientToolCallState.pending: - return `Review ${method} ${url}` - case ClientToolCallState.error: - return `Failed ${method} ${url}` - case ClientToolCallState.rejected: - return `Skipped ${method} ${url}` - case ClientToolCallState.aborted: - return `Aborted ${method} ${url}` - } - } - return undefined - }, -} - const META_manage_custom_tool: ToolMetadata = { displayNames: { [ClientToolCallState.generating]: { @@ -2327,7 +2261,6 @@ const TOOL_METADATA_BY_ID: Record = { list_folders: META_list_folders, list_user_workspaces: META_list_user_workspaces, list_workspace_mcp_servers: META_list_workspace_mcp_servers, - make_api_request: META_make_api_request, manage_custom_tool: META_manage_custom_tool, manage_mcp_tool: META_manage_mcp_tool, manage_skill: META_manage_skill, diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts new file mode 100644 index 0000000000..40431c7445 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts @@ -0,0 +1,89 @@ +/** + * @vitest-environment node + */ + +import { loggerMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { getOrMaterializeVFS } = vi.hoisted(() => ({ + getOrMaterializeVFS: vi.fn(), +})) + +const { readChatUpload } = vi.hoisted(() => ({ + readChatUpload: vi.fn(), +})) + +vi.mock('@sim/logger', () => loggerMock) +vi.mock('@/lib/copilot/vfs', () => ({ + getOrMaterializeVFS, +})) +vi.mock('./upload-file-reader', () => ({ + readChatUpload, + listChatUploads: vi.fn(), +})) + +import { executeVfsGrep, executeVfsRead } from './vfs' + +function makeVfs() { + return { + grep: vi.fn(), + read: vi.fn(), + readFileContent: vi.fn(), + suggestSimilar: vi.fn().mockReturnValue([]), + } +} + +describe('vfs handlers oversize policy', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('fails oversized grep results with narrowing guidance', async () => { + const vfs = makeVfs() + vfs.grep.mockReturnValue([ + { path: 'files/a.txt', line: 1, content: 'a'.repeat(60_000) }, + { path: 'files/b.txt', line: 2, content: 'b'.repeat(60_000) }, + ]) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsGrep( + { pattern: 'foo', output_mode: 'content' }, + { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('smaller grep') + }) + + it('fails oversized read results with grep guidance', async () => { + const vfs = makeVfs() + vfs.read.mockReturnValue({ content: 'a'.repeat(100_001), totalLines: 1 }) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsRead( + { path: 'files/big.txt' }, + { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('Use grep') + }) + + it('fails file-backed oversized read placeholders with grep guidance', async () => { + const vfs = makeVfs() + vfs.read.mockReturnValue(null) + vfs.readFileContent.mockResolvedValue({ + content: '[File too large to display inline: big.txt (6000000 bytes, limit 5242880)]', + totalLines: 1, + }) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsRead( + { path: 'files/big.txt' }, + { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('Use grep') + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index 4cd6f6844f..9a9afe5ab0 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -1,10 +1,26 @@ import { createLogger } from '@sim/logger' +import { TOOL_RESULT_MAX_INLINE_CHARS } from '@/lib/copilot/constants' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { getOrMaterializeVFS } from '@/lib/copilot/vfs' import { listChatUploads, readChatUpload } from './upload-file-reader' const logger = createLogger('VfsTools') +function serializedResultSize(value: unknown): number { + try { + return JSON.stringify(value).length + } catch { + return String(value).length + } +} + +function isOversizedReadPlaceholder(content: string): boolean { + return ( + content.startsWith('[File too large to display inline:') || + content.startsWith('[Image too large:') + ) +} + export async function executeVfsGrep( params: Record, context: ExecutionContext @@ -36,8 +52,16 @@ export async function executeVfsGrep( : typeof result === 'object' ? Object.keys(result).length : 0 + const output = { [key]: result } + if (serializedResultSize(output) > TOOL_RESULT_MAX_INLINE_CHARS) { + return { + success: false, + error: + 'Grep result too large to return inline. Use a smaller grep by narrowing the pattern/path, reducing context, or lowering maxResults.', + } + } logger.debug('vfs_grep result', { pattern, path: params.path, outputMode, matchCount }) - return { success: true, output: { [key]: result } } + return { success: true, output } } catch (err) { logger.error('vfs_grep failed', { pattern, @@ -106,6 +130,16 @@ export async function executeVfsRead( const filename = path.slice('uploads/'.length) const uploadResult = await readChatUpload(filename, context.chatId) if (uploadResult) { + if ( + isOversizedReadPlaceholder(uploadResult.content) || + serializedResultSize(uploadResult) > TOOL_RESULT_MAX_INLINE_CHARS + ) { + return { + success: false, + error: + 'Read result too large to return inline. Use grep on this path instead of reading it directly, or retry read with offset/limit.', + } + } logger.debug('vfs_read resolved chat upload', { path, totalLines: uploadResult.totalLines }) return { success: true, output: uploadResult } } @@ -124,6 +158,16 @@ export async function executeVfsRead( if (!result) { const fileContent = await vfs.readFileContent(path) if (fileContent) { + if ( + isOversizedReadPlaceholder(fileContent.content) || + serializedResultSize(fileContent) > TOOL_RESULT_MAX_INLINE_CHARS + ) { + return { + success: false, + error: + 'Read result too large to return inline. Use grep on this path instead of reading it directly, or retry read with offset/limit.', + } + } logger.debug('vfs_read resolved workspace file', { path, totalLines: fileContent.totalLines, @@ -142,6 +186,16 @@ export async function executeVfsRead( : ' Use glob to discover available paths.' return { success: false, error: `File not found: ${path}.${hint}` } } + if ( + isOversizedReadPlaceholder(result.content) || + serializedResultSize(result) > TOOL_RESULT_MAX_INLINE_CHARS + ) { + return { + success: false, + error: + 'Read result too large to return inline. Use grep on this path instead of reading it directly, or retry read with offset/limit.', + } + } logger.debug('vfs_read result', { path, totalLines: result.totalLines }) return { success: true, diff --git a/apps/sim/lib/copilot/tools/server/other/make-api-request.ts b/apps/sim/lib/copilot/tools/server/other/make-api-request.ts deleted file mode 100644 index 3f95460511..0000000000 --- a/apps/sim/lib/copilot/tools/server/other/make-api-request.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { createLogger } from '@sim/logger' -import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import { executeTool } from '@/tools' -import type { TableRow } from '@/tools/types' - -const RESULT_CHAR_CAP = Number(process.env.COPILOT_TOOL_RESULT_CHAR_CAP || 20000) - -interface MakeApiRequestParams { - url: string - method: 'GET' | 'POST' | 'PUT' - queryParams?: Record - headers?: Record - body?: unknown -} - -interface ApiResponse { - data: string - status: number - headers: Record - truncated?: boolean - totalChars?: number - previewChars?: number - note?: string -} - -export const makeApiRequestServerTool: BaseServerTool = { - name: 'make_api_request', - async execute(params: MakeApiRequestParams): Promise { - const logger = createLogger('MakeApiRequestServerTool') - const { url, method, queryParams, headers, body } = params - if (!url || !method) throw new Error('url and method are required') - - const toTableRows = (obj?: Record): TableRow[] | null => { - if (!obj || typeof obj !== 'object') return null - return Object.entries(obj).map(([key, value]) => ({ - id: key, - cells: { Key: key, Value: value }, - })) - } - const headersTable = toTableRows(headers) - const queryParamsTable = toTableRows(queryParams as Record | undefined) - - const result = await executeTool( - 'http_request', - { url, method, params: queryParamsTable, headers: headersTable, body }, - true - ) - if (!result.success) throw new Error(result.error ?? 'API request failed') - - const output = result.output as Record | undefined - const nestedOutput = output?.output as Record | undefined - const data = nestedOutput?.data ?? output?.data - const status = (nestedOutput?.status ?? output?.status ?? 200) as number - const respHeaders = (nestedOutput?.headers ?? output?.headers ?? {}) as Record - - const toStringSafe = (val: unknown): string => { - if (typeof val === 'string') return val - try { - return JSON.stringify(val) - } catch { - return String(val) - } - } - - const stripHtml = (html: string): string => { - try { - let text = html - let previous: string - do { - previous = text - text = text.replace(//gi, '') - text = text.replace(//gi, '') - text = text.replace(/<[^>]*>/g, ' ') - text = text.replace(/[<>]/g, ' ') - } while (text !== previous) - return text.replace(/\s+/g, ' ').trim() - } catch { - return html - } - } - - let normalized = toStringSafe(data) - const looksLikeHtml = - //i.test(normalized) || //i.test(normalized) - if (looksLikeHtml) normalized = stripHtml(normalized) - - const totalChars = normalized.length - if (totalChars > RESULT_CHAR_CAP) { - const preview = normalized.slice(0, RESULT_CHAR_CAP) - logger.warn('API response truncated', { url, method, totalChars, cap: RESULT_CHAR_CAP }) - return { - data: preview, - status, - headers: respHeaders, - truncated: true, - totalChars, - previewChars: preview.length, - note: `Response truncated to ${RESULT_CHAR_CAP} characters`, - } - } - - logger.debug('API request executed', { url, method, status, totalChars }) - return { data: normalized, status, headers: respHeaders } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index be81a5f585..cd18e19c48 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -26,7 +26,6 @@ import { validateGeneratedToolPayload } from '@/lib/copilot/tools/server/generat import { generateImageServerTool } from '@/lib/copilot/tools/server/image/generate-image' import { getJobLogsServerTool } from '@/lib/copilot/tools/server/jobs/get-job-logs' import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base' -import { makeApiRequestServerTool } from '@/lib/copilot/tools/server/other/make-api-request' import { searchOnlineServerTool } from '@/lib/copilot/tools/server/other/search-online' import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials' @@ -117,7 +116,6 @@ const serverToolRegistry: Record = { [searchOnlineServerTool.name]: searchOnlineServerTool, [setEnvironmentVariablesServerTool.name]: setEnvironmentVariablesServerTool, [getCredentialsServerTool.name]: getCredentialsServerTool, - [makeApiRequestServerTool.name]: makeApiRequestServerTool, [knowledgeBaseServerTool.name]: knowledgeBaseServerTool, [userTableServerTool.name]: userTableServerTool, [workspaceFileServerTool.name]: workspaceFileServerTool,