From 4851489ebcf35f5e37c488de59e7223fe59f58a3 Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:57:35 +0200 Subject: [PATCH] fix(ai-chat) fix AI chat tool-output spill leaks: spill learn_tools, cap navigation tools, truncate on spill failure (#23286) ## Context AI chat spills tool outputs larger than `MAX_INLINE_TOOL_OUTPUT_BYTES` (16 kB) to a file and lets the model page them back with `search_output` / `extract_json_paths`. Three paths bypass this and let unbounded payloads into conversation history: 1. **`learn_tools` is never spilled.** Tool schemas go inline whatever their size. 2. **Navigation tools have no inline cap.** They are exempt from spilling by design (they page spilled files), but nothing bounds their own output. 3. **Spill failure falls back to full inline.** On any spill error the service returns the complete payload with only a warning appended. ## What changed - **`learn_tools` now spills.** `createLearnToolsTool` takes `{ excludeTools?, spillLargeOutput? }` (same shape as `createExecuteToolTool`); chat execution enables it. Only the bulky `tools` schemas are spilled; `message` / `notFound` / `suggestions` stay inline and the response carries a `spilledTools` envelope (fileId, preview, hint) pageable via `extract_json_paths`. MCP is unchanged. - **Navigation tools get a hard inline cap.** Still never spilled, but output above 16 kB is head+tail truncated with a marker telling the model to narrow the query or page with `offset`. - **Spill failure truncates instead of inlining.** The fallback returns head+tail within the 16 kB budget with the original byte size in the marker, keeping the warning. - New `truncateHeadTail` util: byte-budgeted, marker-aware, UTF-8 codepoint-safe. ## Test plan - `tool-output-spill.service.spec.ts`: spill envelope unchanged, under-budget passthrough, navigation cap for both tools (budget respected, marker mentions `offset`, no file written), truncated fallback on spill failure with warnings preserved. - `learn-tools.tool.spec.ts`: no spill without the option, inline under budget, `message`/`notFound`/`suggestions` intact when spilled, spill-failure warnings surfaced. - `truncate-head-tail.util.spec.ts`: budget, head+tail+marker, multibyte-safe cuts. - 63 tests across 6 suites; `lint:diff-with-main` and `typecheck` green. ## Post-deploy Watch the `AiChatToolOutputTokens` histogram (p95 should collapse to ~4k tokens) and the `AiChatInputTokens` / `AiChatCacheReadTokens` ratio on GPT-5-class models. Review in cubic --- .../api/mcp/services/mcp-protocol.service.ts | 8 +- .../services/tool-registry.service.ts | 12 ++ .../tools/__tests__/learn-tools.tool.spec.ts | 158 +++++++++++++++++- .../tool-provider/tools/learn-tools.tool.ts | 45 ++++- .../tool-output-spill.service.spec.ts | 79 ++++++++- .../services/tool-output-spill.service.ts | 54 +++++- .../__tests__/truncate-head-tail.util.spec.ts | 42 +++++ .../tool/utils/truncate-head-tail.util.ts | 85 ++++++++++ .../services/chat-execution.service.ts | 1 + 9 files changed, 459 insertions(+), 25 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/tool/utils/__tests__/truncate-head-tail.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool/utils/truncate-head-tail.util.ts diff --git a/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts b/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts index f9137661d8..7526287c52 100644 --- a/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts +++ b/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts @@ -268,11 +268,9 @@ export class McpProtocolService { annotations: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS, } as McpAnnotatedTool, [LEARN_TOOLS_TOOL_NAME]: { - ...createLearnToolsTool( - this.toolRegistry, - toolContext, - MCP_EXCLUDED_TOOL_NAMES, - ), + ...createLearnToolsTool(this.toolRegistry, toolContext, { + excludeTools: MCP_EXCLUDED_TOOL_NAMES, + }), inputSchema: zodSchema(learnToolsInputSchema), annotations: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS, } as McpAnnotatedTool, diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts index cc0d781574..5aca1fe678 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts @@ -336,6 +336,18 @@ export class ToolRegistryService { } } + async spillToolOutputIfTooLarge( + output: ToolOutput, + context: ToolContext, + toolName: string, + ): Promise { + return this.toolOutputSpillService.spillIfTooLarge( + output, + { workspaceId: context.workspaceId }, + { toolName }, + ); + } + // Eager loading tools by categories (MCP, workflow agent). // These paths need full schemas, so generate with includeSchemas: true. async getToolsByCategories( diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/__tests__/learn-tools.tool.spec.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/__tests__/learn-tools.tool.spec.ts index 14655aac88..cbbd6635d7 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/__tests__/learn-tools.tool.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/__tests__/learn-tools.tool.spec.ts @@ -83,11 +83,9 @@ describe('createLearnToolsTool', () => { suggestSimilarToolNames, } as unknown as ToolRegistryService; - const learnTools = createLearnToolsTool( - toolRegistry, - context, - new Set(['code_interpreter']), - ); + const learnTools = createLearnToolsTool(toolRegistry, context, { + excludeTools: new Set(['code_interpreter']), + }); const result = await learnTools.execute({ toolNames: ['code_interpreter'], @@ -102,4 +100,154 @@ describe('createLearnToolsTool', () => { expect(suggestSimilarToolNames).not.toHaveBeenCalled(); expect(result.message).toBe('No matching tools found.'); }); + + it('does not consult the spill service when spillLargeOutput is not set', async () => { + const spillToolOutputIfTooLarge = jest.fn(); + const toolRegistry = { + getToolInfo: jest + .fn() + .mockResolvedValue([ + { name: 'find_many_people', inputSchema: { type: 'object' } }, + ]), + suggestSimilarToolNames: jest.fn(), + spillToolOutputIfTooLarge, + } as unknown as ToolRegistryService; + + const learnTools = createLearnToolsTool(toolRegistry, context); + + await learnTools.execute({ + toolNames: ['find_many_people'], + aspects: ['schema'], + }); + + expect(spillToolOutputIfTooLarge).not.toHaveBeenCalled(); + }); + + it('keeps tools inline when the spill service leaves the output untouched', async () => { + const spillToolOutputIfTooLarge = jest + .fn() + .mockImplementation((output) => Promise.resolve(output)); + const toolRegistry = { + getToolInfo: jest + .fn() + .mockResolvedValue([ + { name: 'find_many_people', inputSchema: { type: 'object' } }, + ]), + suggestSimilarToolNames: jest.fn(), + spillToolOutputIfTooLarge, + } as unknown as ToolRegistryService; + + const learnTools = createLearnToolsTool(toolRegistry, context, { + spillLargeOutput: true, + }); + + const result = await learnTools.execute({ + toolNames: ['find_many_people'], + aspects: ['schema'], + }); + + expect(spillToolOutputIfTooLarge).toHaveBeenCalledWith( + { + success: true, + message: 'Learned 1 tool: find_many_people.', + result: { + tools: [ + { name: 'find_many_people', inputSchema: { type: 'object' } }, + ], + }, + }, + context, + 'learn_tools', + ); + expect(result.tools).toEqual([ + { name: 'find_many_people', inputSchema: { type: 'object' } }, + ]); + expect(result.spilledTools).toBeUndefined(); + expect(result.warnings).toBeUndefined(); + }); + + it('spills bulky schemas while keeping message, notFound and suggestions inline', async () => { + const spillEnvelope = { + spilled: true, + outputRef: { + fileId: 'file-1', + filename: 'tool-output-learn_tools-file-1.json', + }, + preview: { tools: ['...'] }, + hint: 'use extract_json_paths', + }; + const spillToolOutputIfTooLarge = jest.fn().mockResolvedValue({ + success: true, + message: 'Learned 1 tool: find_many_people.', + result: spillEnvelope, + }); + const toolRegistry = { + getToolInfo: jest + .fn() + .mockResolvedValue([ + { name: 'find_many_people', inputSchema: { type: 'object' } }, + ]), + suggestSimilarToolNames: jest + .fn() + .mockResolvedValue({ group_by_person: ['group_by_people'] }), + spillToolOutputIfTooLarge, + } as unknown as ToolRegistryService; + + const learnTools = createLearnToolsTool(toolRegistry, context, { + spillLargeOutput: true, + }); + + const result = await learnTools.execute({ + toolNames: ['find_many_people', 'group_by_person'], + aspects: ['description', 'schema'], + }); + + expect(result.tools).toEqual([]); + expect(result.spilledTools).toEqual(spillEnvelope); + expect(result.notFound).toEqual(['group_by_person']); + expect(result.suggestions).toEqual({ + group_by_person: ['group_by_people'], + }); + expect(result.message).toContain('Learned 1 tool: find_many_people'); + expect(result.message).toContain( + 'group_by_person (did you mean: group_by_people?)', + ); + }); + + it('surfaces spill warnings when the spill service degrades to truncation', async () => { + const spillToolOutputIfTooLarge = jest.fn().mockResolvedValue({ + success: true, + message: 'Learned 1 tool: find_many_people.', + result: { truncated: true, originalSizeBytes: 90000, content: '...' }, + warnings: ['Large output spill failed; the output was truncated inline.'], + }); + const toolRegistry = { + getToolInfo: jest + .fn() + .mockResolvedValue([ + { name: 'find_many_people', inputSchema: { type: 'object' } }, + ]), + suggestSimilarToolNames: jest.fn(), + spillToolOutputIfTooLarge, + } as unknown as ToolRegistryService; + + const learnTools = createLearnToolsTool(toolRegistry, context, { + spillLargeOutput: true, + }); + + const result = await learnTools.execute({ + toolNames: ['find_many_people'], + aspects: ['schema'], + }); + + expect(result.tools).toEqual([]); + expect(result.spilledTools).toEqual({ + truncated: true, + originalSizeBytes: 90000, + content: '...', + }); + expect(result.warnings).toEqual([ + 'Large output spill failed; the output was truncated inline.', + ]); + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts index 47898ce7e2..006a5e3d58 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts @@ -1,7 +1,9 @@ +import { isDefined } from 'twenty-shared/utils'; import { z } from 'zod'; import { type ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; import { type ToolContext } from 'src/engine/core-modules/tool-provider/types/tool-context.type'; +import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; export const LEARN_TOOLS_TOOL_NAME = 'learn_tools'; @@ -37,12 +39,19 @@ export type LearnToolsResult = { notFound: string[]; suggestions?: Record; message: string; + spilledTools?: object; + warnings?: string[]; +}; + +export type LearnToolsOptions = { + excludeTools?: Set; + spillLargeOutput?: boolean; }; export const createLearnToolsTool = ( toolRegistry: ToolRegistryService, context: ToolContext, - excludeTools?: Set, + options?: LearnToolsOptions, ) => ({ description: 'Get input schemas for tools. Pass all the tool names you need in a single call (toolNames accepts an array) rather than calling learn_tools once per tool. Call this with exact tool names to learn the required arguments before calling execute_tool.', @@ -50,6 +59,7 @@ export const createLearnToolsTool = ( execute: async (parameters: LearnToolsInput): Promise => { const { toolNames, aspects } = parameters; + const excludeTools = options?.excludeTools; const allowedNames = excludeTools ? toolNames.filter((name) => !excludeTools.has(name)) : toolNames; @@ -95,7 +105,7 @@ export const createLearnToolsTool = ( messageParts.push(`Could not find: ${notFoundDescription}`); } - return { + const learnToolsResult: LearnToolsResult = { tools: toolInfos, notFound, ...(Object.keys(suggestions).length > 0 && { suggestions }), @@ -104,5 +114,36 @@ export const createLearnToolsTool = ( ? `${messageParts.join('. ')}.` : 'No matching tools found.', }; + + if (options?.spillLargeOutput !== true) { + return learnToolsResult; + } + + const spillCandidate: ToolOutput = { + success: true, + message: learnToolsResult.message, + result: { tools: learnToolsResult.tools }, + }; + + const spillOutcome = await toolRegistry.spillToolOutputIfTooLarge( + spillCandidate, + context, + LEARN_TOOLS_TOOL_NAME, + ); + + if (spillOutcome === spillCandidate) { + return learnToolsResult; + } + + return { + ...learnToolsResult, + tools: [], + ...(isDefined(spillOutcome.result) && { + spilledTools: spillOutcome.result, + }), + ...(isDefined(spillOutcome.warnings) && { + warnings: spillOutcome.warnings, + }), + }; }, }); diff --git a/packages/twenty-server/src/engine/core-modules/tool/services/__tests__/tool-output-spill.service.spec.ts b/packages/twenty-server/src/engine/core-modules/tool/services/__tests__/tool-output-spill.service.spec.ts index 1e948b4db5..5d994c5871 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/services/__tests__/tool-output-spill.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/services/__tests__/tool-output-spill.service.spec.ts @@ -3,6 +3,7 @@ import { FileFolder } from 'twenty-shared/types'; import { type ApplicationService } from 'src/engine/core-modules/application/application.service'; import { type FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { ToolOutputSpillService } from 'src/engine/core-modules/tool/services/tool-output-spill.service'; +import { MAX_INLINE_TOOL_OUTPUT_BYTES } from 'src/engine/core-modules/tool/tools/output-navigation-tool/constants/max-inline-tool-output-bytes.constant'; import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; jest.mock( @@ -108,9 +109,13 @@ describe('ToolOutputSpillService', () => { }); it.each(['extract_json_paths', 'search_output'])( - 'never spills the output of the %s navigation tool', + 'returns %s navigation output under the byte budget unchanged', async (toolName) => { - const output = buildLargeOutput(); + const output: ToolOutput = { + success: true, + message: 'ok', + result: { matches: ['small'] }, + }; const result = await service.spillIfTooLarge( output, @@ -123,10 +128,41 @@ describe('ToolOutputSpillService', () => { }, ); - it('falls back to the inline output with a warning when the spill write fails', async () => { + it.each(['extract_json_paths', 'search_output'])( + 'caps oversized %s navigation output inline instead of spilling it', + async (toolName) => { + const output = buildLargeOutput(); + const serialized = JSON.stringify(output); + + const result = await service.spillIfTooLarge( + output, + { workspaceId: WORKSPACE_ID }, + { toolName }, + ); + + expect(writeFile).not.toHaveBeenCalled(); + + const capped = result.result as Record; + const content = capped.content as string; + + expect(capped.truncated).toBe(true); + expect(capped.originalSizeBytes).toBe(Buffer.byteLength(serialized)); + expect(Buffer.byteLength(content)).toBeLessThanOrEqual( + MAX_INLINE_TOOL_OUTPUT_BYTES, + ); + expect(content.startsWith(serialized.slice(0, 100))).toBe(true); + expect(content.endsWith(serialized.slice(-100))).toBe(true); + expect(content).toContain('TRUNCATED'); + expect(content).toContain('offset'); + expect(result.message).toBe(output.message); + }, + ); + + it('truncates the output inline with a warning when the spill write fails', async () => { writeFile.mockRejectedValue(new Error('storage down')); const output = buildLargeOutput(); + const serialized = JSON.stringify(output); const result = await service.spillIfTooLarge( output, @@ -134,9 +170,42 @@ describe('ToolOutputSpillService', () => { { toolName: 'find_many_companies' }, ); - expect((result.result as Record).items).toBeDefined(); + const truncated = result.result as Record; + const content = truncated.content as string; + + expect(truncated.truncated).toBe(true); + expect(truncated.originalSizeBytes).toBe(Buffer.byteLength(serialized)); + expect(Buffer.byteLength(content)).toBeLessThanOrEqual( + MAX_INLINE_TOOL_OUTPUT_BYTES, + ); + expect(content.startsWith(serialized.slice(0, 100))).toBe(true); + expect(content.endsWith(serialized.slice(-100))).toBe(true); + expect(content).toContain(`${Buffer.byteLength(serialized)} bytes`); + expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThan( + Buffer.byteLength(serialized), + ); expect(result.warnings).toEqual([ - 'Large output spill failed; the full output is returned inline.', + 'Large output spill failed; the output was truncated inline.', + ]); + }); + + it('keeps pre-existing warnings when the spill write fails', async () => { + writeFile.mockRejectedValue(new Error('storage down')); + + const output: ToolOutput = { + ...buildLargeOutput(), + warnings: ['existing warning'], + }; + + const result = await service.spillIfTooLarge( + output, + { workspaceId: WORKSPACE_ID }, + { toolName: 'find_many_companies' }, + ); + + expect(result.warnings).toEqual([ + 'existing warning', + 'Large output spill failed; the output was truncated inline.', ]); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/tool/services/tool-output-spill.service.ts b/packages/twenty-server/src/engine/core-modules/tool/services/tool-output-spill.service.ts index 4912d62e56..3c68adb440 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/services/tool-output-spill.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/services/tool-output-spill.service.ts @@ -12,6 +12,7 @@ import { OUTPUT_NAVIGATION_TOOL_NAMES } from 'src/engine/core-modules/tool/tools import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; import { formatBytes } from 'src/engine/core-modules/tool/utils/format-bytes.util'; import { jsonPreview } from 'src/engine/core-modules/tool/utils/json-preview.util'; +import { truncateHeadTail } from 'src/engine/core-modules/tool/utils/truncate-head-tail.util'; type SpillContext = { workspaceId: string; @@ -25,6 +26,12 @@ const OUTPUT_NAVIGATION_TOOL_NAME_SET = new Set( OUTPUT_NAVIGATION_TOOL_NAMES, ); +const NAVIGATION_TOOL_TRUNCATION_GUIDANCE = + 'Narrow the query (more specific pattern or paths, lower maxMatches/maxItems/contextChars) or page through the data with the offset parameter to reach the omitted middle.'; + +const SPILL_FAILURE_TRUNCATION_GUIDANCE = + 'Re-run the tool with narrower filters or pagination to retrieve the omitted middle.'; + @Injectable() export class ToolOutputSpillService { private readonly logger = new Logger(ToolOutputSpillService.name); @@ -43,10 +50,6 @@ export class ToolOutputSpillService { return output; } - if (OUTPUT_NAVIGATION_TOOL_NAME_SET.has(options.toolName)) { - return output; - } - let serialized: string | undefined; try { @@ -65,6 +68,15 @@ export class ToolOutputSpillService { return output; } + if (OUTPUT_NAVIGATION_TOOL_NAME_SET.has(options.toolName)) { + return this.buildTruncatedInlineOutput( + output, + serialized, + sizeBytes, + NAVIGATION_TOOL_TRUNCATION_GUIDANCE, + ); + } + try { const preview = jsonPreview(output); const fileId = v4(); @@ -89,7 +101,7 @@ export class ToolOutputSpillService { }, }); - const hint = `Output too large to inline (${formatBytes(sizeBytes)}). "preview" is a truncated sample (first items, all keys); use extract_json_paths (for json objects) or search_output (for text) with this fileId to read the full data, or code_interpreter for analysis.`; + const hint = `Output too large to inline (${formatBytes(sizeBytes)}). "preview" is a truncated sample (first items, all keys). To read the full data, call learn_tools with extract_json_paths (for json objects) or search_output (for text), then invoke it through execute_tool with this fileId; or use code_interpreter for analysis. These are registry tools, not directly callable.`; return { success: output.success, @@ -106,17 +118,43 @@ export class ToolOutputSpillService { }; } catch (error) { this.logger.warn( - `Failed to spill large output for "${options.toolName}"; returning full output inline.`, + `Failed to spill large output for "${options.toolName}"; returning truncated output inline.`, error, ); return { - ...output, + ...this.buildTruncatedInlineOutput( + output, + serialized, + sizeBytes, + SPILL_FAILURE_TRUNCATION_GUIDANCE, + ), warnings: [ ...(output.warnings ?? []), - 'Large output spill failed; the full output is returned inline.', + 'Large output spill failed; the output was truncated inline.', ], }; } } + + private buildTruncatedInlineOutput( + output: ToolOutput, + serialized: string, + sizeBytes: number, + guidance: string, + ): ToolOutput { + return { + success: output.success, + message: output.message, + result: { + truncated: true, + originalSizeBytes: sizeBytes, + content: truncateHeadTail({ + text: serialized, + maxBytes: MAX_INLINE_TOOL_OUTPUT_BYTES, + guidance, + }), + }, + }; + } } diff --git a/packages/twenty-server/src/engine/core-modules/tool/utils/__tests__/truncate-head-tail.util.spec.ts b/packages/twenty-server/src/engine/core-modules/tool/utils/__tests__/truncate-head-tail.util.spec.ts new file mode 100644 index 0000000000..a883bccb98 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/utils/__tests__/truncate-head-tail.util.spec.ts @@ -0,0 +1,42 @@ +import { truncateHeadTail } from 'src/engine/core-modules/tool/utils/truncate-head-tail.util'; + +describe('truncateHeadTail', () => { + const guidance = 'Use a narrower query.'; + + it('returns the text unchanged when under the byte budget', () => { + const text = 'short output'; + + expect(truncateHeadTail({ text, maxBytes: 1000, guidance })).toBe(text); + }); + + it('keeps the head and tail around a marker and respects the byte budget', () => { + const text = `HEAD${'x'.repeat(5000)}TAIL`; + + const truncated = truncateHeadTail({ text, maxBytes: 1000, guidance }); + + expect(Buffer.byteLength(truncated)).toBeLessThanOrEqual(1000); + expect(truncated.startsWith('HEAD')).toBe(true); + expect(truncated.endsWith('TAIL')).toBe(true); + expect(truncated).toContain('TRUNCATED'); + expect(truncated).toContain(`${Buffer.byteLength(text)} bytes`); + expect(truncated).toContain(guidance); + }); + + it('does not split multibyte characters at the cut points', () => { + const text = '€'.repeat(4000); + + const truncated = truncateHeadTail({ text, maxBytes: 1000, guidance }); + + expect(Buffer.byteLength(truncated)).toBeLessThanOrEqual(1000); + expect(truncated).not.toContain('�'); + }); + + it('does not split surrogate pairs at the cut points', () => { + const text = '😀'.repeat(4000); + + const truncated = truncateHeadTail({ text, maxBytes: 1000, guidance }); + + expect(Buffer.byteLength(truncated)).toBeLessThanOrEqual(1000); + expect(truncated).not.toContain('�'); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/tool/utils/truncate-head-tail.util.ts b/packages/twenty-server/src/engine/core-modules/tool/utils/truncate-head-tail.util.ts new file mode 100644 index 0000000000..3d5c5b72b1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/utils/truncate-head-tail.util.ts @@ -0,0 +1,85 @@ +import { formatBytes } from 'src/engine/core-modules/tool/utils/format-bytes.util'; + +type TruncateHeadTailArgs = { + text: string; + maxBytes: number; + guidance: string; +}; + +const isUtf8ContinuationByte = (byte: number): boolean => + (byte & 0b11000000) === 0b10000000; + +const isHighSurrogate = (code: number): boolean => + code >= 0xd800 && code <= 0xdbff; + +const isLowSurrogate = (code: number): boolean => + code >= 0xdc00 && code <= 0xdfff; + +const decodeHead = (text: string, budgetBytes: number): string => { + if (budgetBytes <= 0) { + return ''; + } + + let sliceEnd = Math.min(budgetBytes, text.length); + + // Keep surrogate pairs intact when the char budget lands mid-pair. + if ( + sliceEnd < text.length && + isHighSurrogate(text.charCodeAt(sliceEnd - 1)) + ) { + sliceEnd += 1; + } + + const buffer = Buffer.from(text.slice(0, sliceEnd), 'utf-8'); + let end = Math.min(budgetBytes, buffer.length); + + while ( + end > 0 && + end < buffer.length && + isUtf8ContinuationByte(buffer[end]) + ) { + end -= 1; + } + + return buffer.subarray(0, end).toString('utf-8'); +}; + +const decodeTail = (text: string, budgetBytes: number): string => { + if (budgetBytes <= 0) { + return ''; + } + + let sliceStart = Math.max(0, text.length - budgetBytes); + + if (sliceStart > 0 && isLowSurrogate(text.charCodeAt(sliceStart))) { + sliceStart -= 1; + } + + const buffer = Buffer.from(text.slice(sliceStart), 'utf-8'); + let start = Math.max(0, buffer.length - budgetBytes); + + while (start < buffer.length && isUtf8ContinuationByte(buffer[start])) { + start += 1; + } + + return buffer.subarray(start).toString('utf-8'); +}; + +export const truncateHeadTail = ({ + text, + maxBytes, + guidance, +}: TruncateHeadTailArgs): string => { + const totalBytes = Buffer.byteLength(text, 'utf-8'); + + if (totalBytes <= maxBytes) { + return text; + } + + const marker = `\n[TRUNCATED: output was ${formatBytes(totalBytes)} (${totalBytes} bytes), above the ${formatBytes(maxBytes)} inline limit; middle omitted, showing head and tail. ${guidance}]\n`; + const contentBudgetBytes = Math.max(0, maxBytes - Buffer.byteLength(marker)); + const headBudgetBytes = Math.ceil(contentBudgetBytes / 2); + const tailBudgetBytes = contentBudgetBytes - headBudgetBytes; + + return `${decodeHead(text, headBudgetBytes)}${marker}${decodeTail(text, tailBudgetBytes)}`; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts index 1a289f9e94..eb0b814ead 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts @@ -221,6 +221,7 @@ export class ChatExecutionService { [LEARN_TOOLS_TOOL_NAME]: createLearnToolsTool( this.toolRegistry, toolContext, + { spillLargeOutput: true }, ), [EXECUTE_TOOL_TOOL_NAME]: createExecuteToolTool( this.toolRegistry,