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.
---
.../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,