mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
Remove tool truncation limits
This commit is contained in:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
89
apps/sim/lib/copilot/tools/handlers/vfs.test.ts
Normal file
89
apps/sim/lib/copilot/tools/handlers/vfs.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user