Skip to content

Commit b49d67e

Browse files
committed
Remove tool truncation limits
1 parent d22f367 commit b49d67e

File tree

6 files changed

+158
-175
lines changed

6 files changed

+158
-175
lines changed

apps/sim/lib/copilot/constants.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,20 @@ export const COPILOT_STATS_API_PATH = '/api/copilot/stats'
5050
/** Maximum entries in the in-memory SSE tool-event dedup cache. */
5151
export const STREAM_BUFFER_MAX_DEDUP_ENTRIES = 1_000
5252

53+
// ---------------------------------------------------------------------------
54+
// Tool result size limits
55+
// ---------------------------------------------------------------------------
56+
57+
/** Approximate max inline tool-result budget before artifact/error handling takes over. */
58+
export const TOOL_RESULT_MAX_INLINE_TOKENS = 50_000
59+
60+
/** Rough chars-per-token estimate used when only serialized text length is available. */
61+
export const TOOL_RESULT_ESTIMATED_CHARS_PER_TOKEN = 4
62+
63+
/** Approximate max inline tool-result size in characters. */
64+
export const TOOL_RESULT_MAX_INLINE_CHARS =
65+
TOOL_RESULT_MAX_INLINE_TOKENS * TOOL_RESULT_ESTIMATED_CHARS_PER_TOKEN
66+
5367
// ---------------------------------------------------------------------------
5468
// Copilot modes
5569
// ---------------------------------------------------------------------------

apps/sim/lib/copilot/tools/client/tool-display-registry.ts

Lines changed: 0 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
FolderPlus,
1212
GitBranch,
1313
Globe,
14-
Globe2,
1514
Grid2x2,
1615
Grid2x2Check,
1716
Grid2x2X,
@@ -960,71 +959,6 @@ const META_list_workspace_mcp_servers: ToolMetadata = {
960959
interrupt: undefined,
961960
}
962961

963-
const META_make_api_request: ToolMetadata = {
964-
displayNames: {
965-
[ClientToolCallState.generating]: { text: 'Preparing API request', icon: Loader2 },
966-
[ClientToolCallState.pending]: { text: 'Review API request', icon: Globe2 },
967-
[ClientToolCallState.executing]: { text: 'Executing API request', icon: Loader2 },
968-
[ClientToolCallState.success]: { text: 'Completed API request', icon: Globe2 },
969-
[ClientToolCallState.error]: { text: 'Failed to execute API request', icon: XCircle },
970-
[ClientToolCallState.rejected]: { text: 'Skipped API request', icon: MinusCircle },
971-
[ClientToolCallState.aborted]: { text: 'Aborted API request', icon: XCircle },
972-
},
973-
interrupt: {
974-
accept: { text: 'Execute', icon: Globe2 },
975-
reject: { text: 'Skip', icon: MinusCircle },
976-
},
977-
uiConfig: {
978-
interrupt: {
979-
accept: { text: 'Execute', icon: Globe2 },
980-
reject: { text: 'Skip', icon: MinusCircle },
981-
showAllowOnce: true,
982-
showAllowAlways: true,
983-
},
984-
paramsTable: {
985-
columns: [
986-
{ key: 'method', label: 'Method', width: '26%', editable: true, mono: true },
987-
{ key: 'url', label: 'Endpoint', width: '74%', editable: true, mono: true },
988-
],
989-
extractRows: (params: Record<string, any>): Array<[string, ...any[]]> => {
990-
return [['request', (params.method || 'GET').toUpperCase(), params.url || '']]
991-
},
992-
},
993-
},
994-
getDynamicText: (params, state) => {
995-
if (params?.url && typeof params.url === 'string') {
996-
const method = params.method || 'GET'
997-
let url = params.url
998-
999-
// Extract domain from URL for cleaner display
1000-
try {
1001-
const urlObj = new URL(url)
1002-
url = urlObj.hostname + urlObj.pathname
1003-
} catch {
1004-
// Use URL as-is if parsing fails
1005-
}
1006-
1007-
switch (state) {
1008-
case ClientToolCallState.success:
1009-
return `${method} ${url} complete`
1010-
case ClientToolCallState.executing:
1011-
return `${method} ${url}`
1012-
case ClientToolCallState.generating:
1013-
return `Preparing ${method} ${url}`
1014-
case ClientToolCallState.pending:
1015-
return `Review ${method} ${url}`
1016-
case ClientToolCallState.error:
1017-
return `Failed ${method} ${url}`
1018-
case ClientToolCallState.rejected:
1019-
return `Skipped ${method} ${url}`
1020-
case ClientToolCallState.aborted:
1021-
return `Aborted ${method} ${url}`
1022-
}
1023-
}
1024-
return undefined
1025-
},
1026-
}
1027-
1028962
const META_manage_custom_tool: ToolMetadata = {
1029963
displayNames: {
1030964
[ClientToolCallState.generating]: {
@@ -2327,7 +2261,6 @@ const TOOL_METADATA_BY_ID: Record<string, ToolMetadata> = {
23272261
list_folders: META_list_folders,
23282262
list_user_workspaces: META_list_user_workspaces,
23292263
list_workspace_mcp_servers: META_list_workspace_mcp_servers,
2330-
make_api_request: META_make_api_request,
23312264
manage_custom_tool: META_manage_custom_tool,
23322265
manage_mcp_tool: META_manage_mcp_tool,
23332266
manage_skill: META_manage_skill,
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { loggerMock } from '@sim/testing'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { getOrMaterializeVFS } = vi.hoisted(() => ({
9+
getOrMaterializeVFS: vi.fn(),
10+
}))
11+
12+
const { readChatUpload } = vi.hoisted(() => ({
13+
readChatUpload: vi.fn(),
14+
}))
15+
16+
vi.mock('@sim/logger', () => loggerMock)
17+
vi.mock('@/lib/copilot/vfs', () => ({
18+
getOrMaterializeVFS,
19+
}))
20+
vi.mock('./upload-file-reader', () => ({
21+
readChatUpload,
22+
listChatUploads: vi.fn(),
23+
}))
24+
25+
import { executeVfsGrep, executeVfsRead } from './vfs'
26+
27+
function makeVfs() {
28+
return {
29+
grep: vi.fn(),
30+
read: vi.fn(),
31+
readFileContent: vi.fn(),
32+
suggestSimilar: vi.fn().mockReturnValue([]),
33+
}
34+
}
35+
36+
describe('vfs handlers oversize policy', () => {
37+
beforeEach(() => {
38+
vi.clearAllMocks()
39+
})
40+
41+
it('fails oversized grep results with narrowing guidance', async () => {
42+
const vfs = makeVfs()
43+
vfs.grep.mockReturnValue([
44+
{ path: 'files/a.txt', line: 1, content: 'a'.repeat(60_000) },
45+
{ path: 'files/b.txt', line: 2, content: 'b'.repeat(60_000) },
46+
])
47+
getOrMaterializeVFS.mockResolvedValue(vfs)
48+
49+
const result = await executeVfsGrep(
50+
{ pattern: 'foo', output_mode: 'content' },
51+
{ userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' }
52+
)
53+
54+
expect(result.success).toBe(false)
55+
expect(result.error).toContain('smaller grep')
56+
})
57+
58+
it('fails oversized read results with grep guidance', async () => {
59+
const vfs = makeVfs()
60+
vfs.read.mockReturnValue({ content: 'a'.repeat(100_001), totalLines: 1 })
61+
getOrMaterializeVFS.mockResolvedValue(vfs)
62+
63+
const result = await executeVfsRead(
64+
{ path: 'files/big.txt' },
65+
{ userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' }
66+
)
67+
68+
expect(result.success).toBe(false)
69+
expect(result.error).toContain('Use grep')
70+
})
71+
72+
it('fails file-backed oversized read placeholders with grep guidance', async () => {
73+
const vfs = makeVfs()
74+
vfs.read.mockReturnValue(null)
75+
vfs.readFileContent.mockResolvedValue({
76+
content: '[File too large to display inline: big.txt (6000000 bytes, limit 5242880)]',
77+
totalLines: 1,
78+
})
79+
getOrMaterializeVFS.mockResolvedValue(vfs)
80+
81+
const result = await executeVfsRead(
82+
{ path: 'files/big.txt' },
83+
{ userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' }
84+
)
85+
86+
expect(result.success).toBe(false)
87+
expect(result.error).toContain('Use grep')
88+
})
89+
})

apps/sim/lib/copilot/tools/handlers/vfs.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,26 @@
11
import { createLogger } from '@sim/logger'
2+
import { TOOL_RESULT_MAX_INLINE_CHARS } from '@/lib/copilot/constants'
23
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
34
import { getOrMaterializeVFS } from '@/lib/copilot/vfs'
45
import { listChatUploads, readChatUpload } from './upload-file-reader'
56

67
const logger = createLogger('VfsTools')
78

9+
function serializedResultSize(value: unknown): number {
10+
try {
11+
return JSON.stringify(value).length
12+
} catch {
13+
return String(value).length
14+
}
15+
}
16+
17+
function isOversizedReadPlaceholder(content: string): boolean {
18+
return (
19+
content.startsWith('[File too large to display inline:') ||
20+
content.startsWith('[Image too large:')
21+
)
22+
}
23+
824
export async function executeVfsGrep(
925
params: Record<string, unknown>,
1026
context: ExecutionContext
@@ -36,8 +52,16 @@ export async function executeVfsGrep(
3652
: typeof result === 'object'
3753
? Object.keys(result).length
3854
: 0
55+
const output = { [key]: result }
56+
if (serializedResultSize(output) > TOOL_RESULT_MAX_INLINE_CHARS) {
57+
return {
58+
success: false,
59+
error:
60+
'Grep result too large to return inline. Use a smaller grep by narrowing the pattern/path, reducing context, or lowering maxResults.',
61+
}
62+
}
3963
logger.debug('vfs_grep result', { pattern, path: params.path, outputMode, matchCount })
40-
return { success: true, output: { [key]: result } }
64+
return { success: true, output }
4165
} catch (err) {
4266
logger.error('vfs_grep failed', {
4367
pattern,
@@ -106,6 +130,16 @@ export async function executeVfsRead(
106130
const filename = path.slice('uploads/'.length)
107131
const uploadResult = await readChatUpload(filename, context.chatId)
108132
if (uploadResult) {
133+
if (
134+
isOversizedReadPlaceholder(uploadResult.content) ||
135+
serializedResultSize(uploadResult) > TOOL_RESULT_MAX_INLINE_CHARS
136+
) {
137+
return {
138+
success: false,
139+
error:
140+
'Read result too large to return inline. Use grep on this path instead of reading it directly, or retry read with offset/limit.',
141+
}
142+
}
109143
logger.debug('vfs_read resolved chat upload', { path, totalLines: uploadResult.totalLines })
110144
return { success: true, output: uploadResult }
111145
}
@@ -124,6 +158,16 @@ export async function executeVfsRead(
124158
if (!result) {
125159
const fileContent = await vfs.readFileContent(path)
126160
if (fileContent) {
161+
if (
162+
isOversizedReadPlaceholder(fileContent.content) ||
163+
serializedResultSize(fileContent) > TOOL_RESULT_MAX_INLINE_CHARS
164+
) {
165+
return {
166+
success: false,
167+
error:
168+
'Read result too large to return inline. Use grep on this path instead of reading it directly, or retry read with offset/limit.',
169+
}
170+
}
127171
logger.debug('vfs_read resolved workspace file', {
128172
path,
129173
totalLines: fileContent.totalLines,
@@ -142,6 +186,16 @@ export async function executeVfsRead(
142186
: ' Use glob to discover available paths.'
143187
return { success: false, error: `File not found: ${path}.${hint}` }
144188
}
189+
if (
190+
isOversizedReadPlaceholder(result.content) ||
191+
serializedResultSize(result) > TOOL_RESULT_MAX_INLINE_CHARS
192+
) {
193+
return {
194+
success: false,
195+
error:
196+
'Read result too large to return inline. Use grep on this path instead of reading it directly, or retry read with offset/limit.',
197+
}
198+
}
145199
logger.debug('vfs_read result', { path, totalLines: result.totalLines })
146200
return {
147201
success: true,

0 commit comments

Comments
 (0)