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.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23286?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Etienne
2026-07-24 18:57:35 +02:00
committed by GitHub
parent 3a1067ec6d
commit 4851489ebc
9 changed files with 459 additions and 25 deletions
@@ -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,
@@ -336,6 +336,18 @@ export class ToolRegistryService {
}
}
async spillToolOutputIfTooLarge(
output: ToolOutput,
context: ToolContext,
toolName: string,
): Promise<ToolOutput> {
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(
@@ -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.',
]);
});
});
@@ -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<string, string[]>;
message: string;
spilledTools?: object;
warnings?: string[];
};
export type LearnToolsOptions = {
excludeTools?: Set<string>;
spillLargeOutput?: boolean;
};
export const createLearnToolsTool = (
toolRegistry: ToolRegistryService,
context: ToolContext,
excludeTools?: Set<string>,
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<LearnToolsResult> => {
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,
}),
};
},
});
@@ -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<string, unknown>;
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<string, unknown>).items).toBeDefined();
const truncated = result.result as Record<string, unknown>;
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.',
]);
});
});
@@ -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<string>(
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,
}),
},
};
}
}
@@ -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('');
});
});
@@ -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)}`;
};
@@ -221,6 +221,7 @@ export class ChatExecutionService {
[LEARN_TOOLS_TOOL_NAME]: createLearnToolsTool(
this.toolRegistry,
toolContext,
{ spillLargeOutput: true },
),
[EXECUTE_TOOL_TOOL_NAME]: createExecuteToolTool(
this.toolRegistry,