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:
@@ -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,
|
||||
|
||||
+12
@@ -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(
|
||||
|
||||
+153
-5
@@ -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.',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+43
-2
@@ -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,
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
+74
-5
@@ -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.',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+46
-8
@@ -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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+42
@@ -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)}`;
|
||||
};
|
||||
+1
@@ -221,6 +221,7 @@ export class ChatExecutionService {
|
||||
[LEARN_TOOLS_TOOL_NAME]: createLearnToolsTool(
|
||||
this.toolRegistry,
|
||||
toolContext,
|
||||
{ spillLargeOutput: true },
|
||||
),
|
||||
[EXECUTE_TOOL_TOOL_NAME]: createExecuteToolTool(
|
||||
this.toolRegistry,
|
||||
|
||||
Reference in New Issue
Block a user