Remove tool truncation limits

This commit is contained in:
Siddharth Ganesan
2026-04-08 18:24:22 -07:00
parent d22f3678fe
commit b49d67e46c
6 changed files with 158 additions and 175 deletions

View File

@@ -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
// ---------------------------------------------------------------------------

View File

@@ -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<string, any>): 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<string, ToolMetadata> = {
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,

View File

@@ -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')
})
})

View File

@@ -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<string, unknown>,
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,

View File

@@ -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<string, string | number | boolean>
headers?: Record<string, string>
body?: unknown
}
interface ApiResponse {
data: string
status: number
headers: Record<string, string>
truncated?: boolean
totalChars?: number
previewChars?: number
note?: string
}
export const makeApiRequestServerTool: BaseServerTool<MakeApiRequestParams, ApiResponse> = {
name: 'make_api_request',
async execute(params: MakeApiRequestParams): Promise<ApiResponse> {
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<string, unknown>): 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<string, unknown> | 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<string, unknown> | undefined
const nestedOutput = output?.output as Record<string, unknown> | undefined
const data = nestedOutput?.data ?? output?.data
const status = (nestedOutput?.status ?? output?.status ?? 200) as number
const respHeaders = (nestedOutput?.headers ?? output?.headers ?? {}) as Record<string, string>
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(/<script[\s\S]*?<\/script\s*>/gi, '')
text = text.replace(/<style[\s\S]*?<\/style\s*>/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 =
/<html[\s\S]*<\/html>/i.test(normalized) || /<body[\s\S]*<\/body>/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 }
},
}

View File

@@ -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<string, BaseServerTool> = {
[searchOnlineServerTool.name]: searchOnlineServerTool,
[setEnvironmentVariablesServerTool.name]: setEnvironmentVariablesServerTool,
[getCredentialsServerTool.name]: getCredentialsServerTool,
[makeApiRequestServerTool.name]: makeApiRequestServerTool,
[knowledgeBaseServerTool.name]: knowledgeBaseServerTool,
[userTableServerTool.name]: userTableServerTool,
[workspaceFileServerTool.name]: workspaceFileServerTool,