fix(tool): Fix custom tools spreading out string output (#3676)

* fix(tool): Fix issue with custom tools spreading out string output

* Fix lint

* Avoid any transformation on custom tool outputs

---------

Co-authored-by: Theodore Li <theo@sim.ai>
This commit is contained in:
Theodore Li
2026-03-19 11:44:38 -07:00
committed by GitHub
parent 27a41d4e33
commit 25789855af
3 changed files with 192 additions and 2 deletions

View File

@@ -24,6 +24,9 @@ export function filterOutputForLog(
additionalHiddenKeys?: string[]
}
): NormalizedBlockOutput {
if (typeof output !== 'object' || output === null || Array.isArray(output)) {
return output as NormalizedBlockOutput
}
const blockConfig = blockType ? getBlock(blockType) : undefined
const filtered: NormalizedBlockOutput = {}
const additionalHiddenKeys = options?.additionalHiddenKeys ?? []

View File

@@ -1830,6 +1830,186 @@ describe('Rate Limiting and Retry Logic', () => {
})
})
describe('stripInternalFields Safety', () => {
let cleanupEnvVars: () => void
beforeEach(() => {
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
cleanupEnvVars = setupEnvVars({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' })
})
afterEach(() => {
vi.resetAllMocks()
cleanupEnvVars()
})
it('should preserve string output from tools without character-indexing', async () => {
const stringOutput = '{"type":"button","phone":"917899658001"}'
const mockTool = {
id: 'test_string_output',
name: 'Test String Output',
description: 'A tool that returns a string as output',
version: '1.0.0',
params: {},
request: {
url: '/api/test/string-output',
method: 'POST' as const,
headers: () => ({ 'Content-Type': 'application/json' }),
},
transformResponse: vi.fn().mockResolvedValue({
success: true,
output: stringOutput,
}),
}
const originalTools = { ...tools }
;(tools as any).test_string_output = mockTool
global.fetch = Object.assign(
vi.fn().mockImplementation(async () => ({
ok: true,
status: 200,
headers: new Headers(),
json: () => Promise.resolve({ success: true }),
})),
{ preconnect: vi.fn() }
) as typeof fetch
const result = await executeTool('test_string_output', {}, true)
expect(result.success).toBe(true)
expect(result.output).toBe(stringOutput)
expect(typeof result.output).toBe('string')
Object.assign(tools, originalTools)
})
it('should preserve array output from tools', async () => {
const arrayOutput = [{ id: 1 }, { id: 2 }]
const mockTool = {
id: 'test_array_output',
name: 'Test Array Output',
description: 'A tool that returns an array as output',
version: '1.0.0',
params: {},
request: {
url: '/api/test/array-output',
method: 'POST' as const,
headers: () => ({ 'Content-Type': 'application/json' }),
},
transformResponse: vi.fn().mockResolvedValue({
success: true,
output: arrayOutput,
}),
}
const originalTools = { ...tools }
;(tools as any).test_array_output = mockTool
global.fetch = Object.assign(
vi.fn().mockImplementation(async () => ({
ok: true,
status: 200,
headers: new Headers(),
json: () => Promise.resolve({ success: true }),
})),
{ preconnect: vi.fn() }
) as typeof fetch
const result = await executeTool('test_array_output', {}, true)
expect(result.success).toBe(true)
expect(Array.isArray(result.output)).toBe(true)
expect(result.output).toEqual(arrayOutput)
Object.assign(tools, originalTools)
})
it('should still strip __-prefixed fields from object output', async () => {
const mockTool = {
id: 'test_strip_internal',
name: 'Test Strip Internal',
description: 'A tool with __internal fields in output',
version: '1.0.0',
params: {},
request: {
url: '/api/test/strip-internal',
method: 'POST' as const,
headers: () => ({ 'Content-Type': 'application/json' }),
},
transformResponse: vi.fn().mockResolvedValue({
success: true,
output: { result: 'ok', __costDollars: 0.05, _id: 'keep-this' },
}),
}
const originalTools = { ...tools }
;(tools as any).test_strip_internal = mockTool
global.fetch = Object.assign(
vi.fn().mockImplementation(async () => ({
ok: true,
status: 200,
headers: new Headers(),
json: () => Promise.resolve({ success: true }),
})),
{ preconnect: vi.fn() }
) as typeof fetch
const result = await executeTool('test_strip_internal', {}, true)
expect(result.success).toBe(true)
expect(result.output.result).toBe('ok')
expect(result.output.__costDollars).toBeUndefined()
expect(result.output._id).toBe('keep-this')
Object.assign(tools, originalTools)
})
it('should preserve __-prefixed fields in custom tool output', async () => {
const mockTool = {
id: 'custom_test-preserve-dunder',
name: 'Custom Preserve Dunder',
description: 'A custom tool whose output has __ fields',
version: '1.0.0',
params: {},
request: {
url: '/api/function/execute',
method: 'POST' as const,
headers: () => ({ 'Content-Type': 'application/json' }),
},
transformResponse: vi.fn().mockResolvedValue({
success: true,
output: { result: 'ok', __metadata: { source: 'user' }, __tag: 'important' },
}),
}
const originalTools = { ...tools }
;(tools as any)['custom_test-preserve-dunder'] = mockTool
global.fetch = Object.assign(
vi.fn().mockImplementation(async () => ({
ok: true,
status: 200,
headers: new Headers(),
json: () => Promise.resolve({ success: true }),
})),
{ preconnect: vi.fn() }
) as typeof fetch
const result = await executeTool('custom_test-preserve-dunder', {}, true)
expect(result.success).toBe(true)
expect(result.output.result).toBe('ok')
expect(result.output.__metadata).toEqual({ source: 'user' })
expect(result.output.__tag).toBe('important')
Object.assign(tools, originalTools)
})
})
describe('Cost Field Handling', () => {
let cleanupEnvVars: () => void

View File

@@ -363,6 +363,9 @@ async function reportCustomDimensionUsage(
* fields like `_id`.
*/
function stripInternalFields(output: Record<string, unknown>): Record<string, unknown> {
if (typeof output !== 'object' || output === null || Array.isArray(output)) {
return output
}
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(output)) {
if (!key.startsWith('__')) {
@@ -825,7 +828,9 @@ export async function executeTool(
)
}
const strippedOutput = stripInternalFields(finalResult.output || {})
const strippedOutput = isCustomTool(normalizedToolId)
? finalResult.output
: stripInternalFields(finalResult.output ?? {})
return {
...finalResult,
@@ -880,7 +885,9 @@ export async function executeTool(
)
}
const strippedOutput = stripInternalFields(finalResult.output || {})
const strippedOutput = isCustomTool(normalizedToolId)
? finalResult.output
: stripInternalFields(finalResult.output ?? {})
return {
...finalResult,