diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json index 3d08e11070..b6a82f47c2 100644 --- a/packages/twenty-server/package.json +++ b/packages/twenty-server/package.json @@ -152,6 +152,7 @@ "pluralize": "8.0.0", "postal-mime": "^2.6.1", "psl": "^1.9.0", + "re2": "^1.25.0", "react": "^19.2.0", "react-dom": "^19.2.0", "redis": "^4.7.0", diff --git a/packages/twenty-server/src/engine/api/mcp/constants/mcp-excluded-tool-names.const.ts b/packages/twenty-server/src/engine/api/mcp/constants/mcp-excluded-tool-names.const.ts index 1f008e0947..e4404f910b 100644 --- a/packages/twenty-server/src/engine/api/mcp/constants/mcp-excluded-tool-names.const.ts +++ b/packages/twenty-server/src/engine/api/mcp/constants/mcp-excluded-tool-names.const.ts @@ -1,4 +1,7 @@ +import { OUTPUT_NAVIGATION_TOOL_NAMES } from 'src/engine/core-modules/tool/tools/output-navigation-tool/constants/output-navigation-tool-names.constant'; + export const MCP_EXCLUDED_TOOL_NAMES = new Set([ 'code_interpreter', 'http_request', + ...OUTPUT_NAVIGATION_TOOL_NAMES, ]); diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-retrieval-options.type.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-retrieval-options.type.ts index aac0da75f6..e20bb0b3f2 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-retrieval-options.type.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-retrieval-options.type.ts @@ -9,4 +9,10 @@ export type ToolRetrievalOptions = { // before returning. Chat enables this to reduce token usage in the // conversation context; MCP and workflow agents leave raw output intact. compactOutput?: boolean; + // Spill oversized dispatch results to a file and return a compact + // { spilled, outputRef, shape, hint } envelope instead of the raw payload. + // Same axis as compactOutput: chat enables it (and has the navigation tools + // to read the file); MCP, workflow agents, and the code_interpreter sandbox + // bridge leave output raw. + spillLargeOutput?: boolean; }; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts index 8cc3a4584e..a1f5f769b4 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts @@ -17,6 +17,8 @@ import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/dr import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool'; import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool'; import { NavigateAppTool } from 'src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool'; +import { ExtractJsonPathsTool } from 'src/engine/core-modules/tool/tools/output-navigation-tool/extract-json-paths-tool'; +import { SearchOutputTool } from 'src/engine/core-modules/tool/tools/output-navigation-tool/search-output-tool'; import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool'; import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; import { type Tool } from 'src/engine/core-modules/tool/types/tool.type'; @@ -35,6 +37,8 @@ export class ActionToolProvider implements ToolProvider { private readonly searchHelpCenterTool: SearchHelpCenterTool, private readonly codeInterpreterTool: CodeInterpreterTool, private readonly navigateAppTool: NavigateAppTool, + private readonly extractJsonPathsTool: ExtractJsonPathsTool, + private readonly searchOutputTool: SearchOutputTool, private readonly codeInterpreterService: CodeInterpreterService, private readonly permissionsService: PermissionsService, ) { @@ -45,6 +49,8 @@ export class ActionToolProvider implements ToolProvider { ['search_help_center', this.searchHelpCenterTool], ['code_interpreter', this.codeInterpreterTool], ['navigate_app', this.navigateAppTool], + ['extract_json_paths', this.extractJsonPathsTool], + ['search_output', this.searchOutputTool], ]); } @@ -106,6 +112,22 @@ export class ActionToolProvider implements ToolProvider { ), ); + descriptors.push( + this.buildDescriptor( + 'extract_json_paths', + this.extractJsonPathsTool, + includeSchemas, + ), + ); + + descriptors.push( + this.buildDescriptor( + 'search_output', + this.searchOutputTool, + includeSchemas, + ), + ); + const hasCodeInterpreterPermission = this.codeInterpreterService.isEnabled() && (await this.permissionsService.hasToolPermission( 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 8fe5eecccd..382515b24a 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 @@ -9,6 +9,7 @@ import { type ToolRetrievalOptions } from 'src/engine/core-modules/tool-provider import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token'; import { compactToolOutput } from 'src/engine/core-modules/tool-provider/output-transforms/compact-tool-output.util'; import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service'; +import { ToolOutputSpillService } from 'src/engine/core-modules/tool/services/tool-output-spill.service'; import { type LearnToolsAspect } from 'src/engine/core-modules/tool-provider/tools/learn-tools.tool'; import { type ToolContext } from 'src/engine/core-modules/tool-provider/types/tool-context.type'; import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type'; @@ -30,6 +31,7 @@ export class ToolRegistryService { @Inject(TOOL_PROVIDERS) private readonly providers: ToolProvider[], private readonly toolExecutorService: ToolExecutorService, + private readonly toolOutputSpillService: ToolOutputSpillService, ) {} async getCatalog(context: ToolProviderContext): Promise { @@ -110,11 +112,13 @@ export class ToolRegistryService { wrapWithErrorContext?: boolean; includeLoadingMessage?: boolean; compactOutput?: boolean; + spillLargeOutput?: boolean; }, ): ToolSet { const toolSet: ToolSet = {}; const includeLoadingMessage = options?.includeLoadingMessage ?? true; const compactOutput = options?.compactOutput ?? false; + const spillLargeOutput = options?.spillLargeOutput ?? false; for (const descriptor of descriptors) { const baseSchema = descriptor.inputSchema as Record; @@ -135,9 +139,17 @@ export class ToolRegistryService { context, ); - return compactOutput + const compacted = compactOutput ? (compactToolOutput(result) as ToolOutput) : result; + + return spillLargeOutput + ? this.toolOutputSpillService.spillIfTooLarge( + compacted, + { workspaceId: context.workspaceId }, + { toolName: descriptor.name }, + ) + : compacted; }; toolSet[descriptor.name] = { @@ -173,6 +185,7 @@ export class ToolRegistryService { options?: { includeLoadingMessage?: boolean; compactOutput?: boolean; + spillLargeOutput?: boolean; }, ): Promise { const fullContext = this.buildContextFromToolContext(context); @@ -197,6 +210,7 @@ export class ToolRegistryService { return this.hydrateToolSet(descriptors, fullContext, { includeLoadingMessage: options?.includeLoadingMessage, compactOutput: options?.compactOutput, + spillLargeOutput: options?.spillLargeOutput, }); } @@ -271,7 +285,7 @@ export class ToolRegistryService { toolName: string, args: Record | undefined, context: ToolContext, - options?: { compactOutput?: boolean }, + options?: { compactOutput?: boolean; spillLargeOutput?: boolean }, ): Promise { try { const fullContext = this.buildContextFromToolContext(context); @@ -302,9 +316,17 @@ export class ToolRegistryService { fullContext, ); - return options?.compactOutput + const compacted = options?.compactOutput ? (compactToolOutput(result) as ToolOutput) : result; + + return options?.spillLargeOutput + ? this.toolOutputSpillService.spillIfTooLarge( + compacted, + { workspaceId: fullContext.workspaceId }, + { toolName }, + ) + : compacted; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -331,6 +353,7 @@ export class ToolRegistryService { wrapWithErrorContext, includeLoadingMessage, compactOutput, + spillLargeOutput, } = options; const categorySet = categories ? new Set(categories) : undefined; @@ -366,6 +389,7 @@ export class ToolRegistryService { wrapWithErrorContext, includeLoadingMessage, compactOutput, + spillLargeOutput, }); this.logger.log( diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts index 658fc77320..4b989c0206 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts @@ -48,6 +48,7 @@ export const createExecuteToolTool = ( options?: { excludeTools?: Set; compactOutput?: boolean; + spillLargeOutput?: boolean; }, ) => ({ description: @@ -66,6 +67,7 @@ export const createExecuteToolTool = ( return toolRegistry.resolveAndExecute(toolName, args, context, { compactOutput: options?.compactOutput, + spillLargeOutput: options?.spillLargeOutput, }); }, }); 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 new file mode 100644 index 0000000000..092df73878 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/services/__tests__/tool-output-spill.service.spec.ts @@ -0,0 +1,139 @@ +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/file-storage.service'; +import { ToolOutputSpillService } from 'src/engine/core-modules/tool/services/tool-output-spill.service'; +import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; + +jest.mock('src/engine/core-modules/file-storage/file-storage.service', () => ({ + FileStorageService: class {}, +})); +jest.mock('src/engine/core-modules/application/application.service', () => ({ + ApplicationService: class {}, +})); + +const WORKSPACE_ID = 'workspace-1'; + +const buildLargeOutput = (): ToolOutput => ({ + success: true, + message: 'ok', + result: { + items: Array.from({ length: 2000 }, (_, index) => ({ + id: `record-${index}`, + label: 'a-fairly-long-label-value-to-inflate-the-payload-size', + })), + }, +}); + +describe('ToolOutputSpillService', () => { + const writeFile = jest.fn(); + const findApplication = jest.fn(); + + const service = new ToolOutputSpillService( + { writeFile } as unknown as FileStorageService, + { + findWorkspaceTwentyStandardAndCustomApplicationOrThrow: findApplication, + } as unknown as ApplicationService, + ); + + beforeEach(() => { + jest.clearAllMocks(); + findApplication.mockResolvedValue({ + workspaceCustomFlatApplication: { universalIdentifier: 'app-uid' }, + }); + // Echo a persisted id that differs from the generated fileId so the test + // catches the envelope exposing the wrong id (the nav tools retrieve by + // savedFile.id, not by the generated uuid). + writeFile.mockImplementation(({ fileId }: { fileId: string }) => + Promise.resolve({ id: `persisted-${fileId}` }), + ); + }); + + it('returns the output unchanged when under the byte budget', async () => { + const output: ToolOutput = { + success: true, + message: 'ok', + result: { id: 'small' }, + }; + + const result = await service.spillIfTooLarge( + output, + { workspaceId: WORKSPACE_ID }, + { toolName: 'find_many_companies' }, + ); + + expect(result).toBe(output); + expect(writeFile).not.toHaveBeenCalled(); + }); + + it('spills oversized output to an AgentChat file and returns an envelope', async () => { + const result = await service.spillIfTooLarge( + buildLargeOutput(), + { workspaceId: WORKSPACE_ID }, + { toolName: 'find_many_companies' }, + ); + + expect(writeFile).toHaveBeenCalledWith( + expect.objectContaining({ + fileFolder: FileFolder.AgentChat, + workspaceId: WORKSPACE_ID, + applicationUniversalIdentifier: 'app-uid', + settings: { isTemporaryFile: false, toDelete: false }, + }), + ); + + const writeFileArgs = writeFile.mock.calls[0][0] as { + fileId: string; + resourcePath: string; + }; + + const envelope = result.result as Record; + const outputRef = envelope.outputRef as Record; + + expect(envelope.spilled).toBe(true); + // The envelope must expose the persisted id (what the nav tools retrieve + // by), not the generated fileId used to build the storage resourcePath. + expect(outputRef.fileId).toBe(`persisted-${writeFileArgs.fileId}`); + expect(writeFileArgs.resourcePath).toBe( + `tool-output-spill/${writeFileArgs.fileId}.json`, + ); + expect(outputRef.filename).toMatch( + /^tool-output-find_many_companies-.+\.json$/, + ); + expect(envelope.preview).toBeDefined(); + expect(envelope.hint).toContain('extract_json_paths'); + }); + + it.each(['extract_json_paths', 'search_output'])( + 'never spills the output of the %s navigation tool', + async (toolName) => { + const output = buildLargeOutput(); + + const result = await service.spillIfTooLarge( + output, + { workspaceId: WORKSPACE_ID }, + { toolName }, + ); + + expect(result).toBe(output); + expect(writeFile).not.toHaveBeenCalled(); + }, + ); + + it('falls back to the inline output with a warning when the spill write fails', async () => { + writeFile.mockRejectedValue(new Error('storage down')); + + const output = buildLargeOutput(); + + const result = await service.spillIfTooLarge( + output, + { workspaceId: WORKSPACE_ID }, + { toolName: 'find_many_companies' }, + ); + + expect((result.result as Record).items).toBeDefined(); + expect(result.warnings).toEqual([ + 'Large output spill failed; the full output is returned 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 new file mode 100644 index 0000000000..e61932c5ff --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/services/tool-output-spill.service.ts @@ -0,0 +1,122 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { FileFolder } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { v4 } from 'uuid'; + +import { isObject } from 'class-validator'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.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 { OUTPUT_NAVIGATION_TOOL_NAMES } from 'src/engine/core-modules/tool/tools/output-navigation-tool/constants/output-navigation-tool-names.constant'; +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'; + +type SpillContext = { + workspaceId: string; +}; + +type SpillOptions = { + toolName: string; +}; + +const OUTPUT_NAVIGATION_TOOL_NAME_SET = new Set( + OUTPUT_NAVIGATION_TOOL_NAMES, +); + +@Injectable() +export class ToolOutputSpillService { + private readonly logger = new Logger(ToolOutputSpillService.name); + + constructor( + private readonly fileStorageService: FileStorageService, + private readonly applicationService: ApplicationService, + ) {} + + async spillIfTooLarge( + output: ToolOutput, + { workspaceId }: SpillContext, + options: SpillOptions, + ): Promise { + if (!isDefined(output) || !isObject(output)) { + return output; + } + + if (OUTPUT_NAVIGATION_TOOL_NAME_SET.has(options.toolName)) { + return output; + } + + let serialized: string | undefined; + + try { + serialized = JSON.stringify(output); + } catch { + return output; + } + + if (!isDefined(serialized)) { + return output; + } + + const sizeBytes = Buffer.byteLength(serialized); + + if (sizeBytes <= MAX_INLINE_TOOL_OUTPUT_BYTES) { + return output; + } + + try { + const preview = jsonPreview(output); + const fileId = v4(); + const filename = `tool-output-${options.toolName}-${fileId}.json`; + + const { workspaceCustomFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ); + + const savedFile = await this.fileStorageService.writeFile({ + sourceFile: Buffer.from(serialized), + fileFolder: FileFolder.AgentChat, + applicationUniversalIdentifier: + workspaceCustomFlatApplication.universalIdentifier, + workspaceId, + resourcePath: `tool-output-spill/${fileId}.json`, + fileId, + settings: { + isTemporaryFile: false, + toDelete: false, + }, + }); + + 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.`; + + return { + success: output.success, + message: output.message, + result: { + spilled: true, + outputRef: { + fileId: savedFile.id, + filename, + }, + preview, + hint, + }, + }; + } catch (error) { + this.logger.warn( + `Failed to spill large output for "${options.toolName}"; returning full output inline.`, + error, + ); + + return { + ...output, + warnings: [ + ...(output.warnings ?? []), + 'Large output spill failed; the full output is returned inline.', + ], + }; + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts b/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts index 333ae79821..287123fa8a 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts @@ -13,7 +13,10 @@ import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-t import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool'; import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool'; import { NavigateAppTool } from 'src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool'; +import { ExtractJsonPathsTool } from 'src/engine/core-modules/tool/tools/output-navigation-tool/extract-json-paths-tool'; +import { SearchOutputTool } from 'src/engine/core-modules/tool/tools/output-navigation-tool/search-output-tool'; import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool'; +import { ToolOutputSpillService } from 'src/engine/core-modules/tool/services/tool-output-spill.service'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module'; @@ -45,6 +48,9 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac SearchHelpCenterTool, CodeInterpreterTool, NavigateAppTool, + ExtractJsonPathsTool, + SearchOutputTool, + ToolOutputSpillService, provideWorkspaceScopedRepository(FileEntity), ], exports: [ @@ -55,6 +61,9 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac SearchHelpCenterTool, CodeInterpreterTool, NavigateAppTool, + ExtractJsonPathsTool, + SearchOutputTool, + ToolOutputSpillService, ], }) export class ToolModule {} diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/default-extract-json-paths-max-depth.constant.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/default-extract-json-paths-max-depth.constant.ts new file mode 100644 index 0000000000..02b6c577d9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/default-extract-json-paths-max-depth.constant.ts @@ -0,0 +1 @@ +export const DEFAULT_EXTRACT_JSON_PATHS_MAX_DEPTH = 5; diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/default-extract-json-paths-max-items.constant.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/default-extract-json-paths-max-items.constant.ts new file mode 100644 index 0000000000..657ef18752 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/default-extract-json-paths-max-items.constant.ts @@ -0,0 +1 @@ +export const DEFAULT_EXTRACT_JSON_PATHS_MAX_ITEMS = 20; diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/default-search-output-context-lines.constant.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/default-search-output-context-lines.constant.ts new file mode 100644 index 0000000000..fc793d8987 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/default-search-output-context-lines.constant.ts @@ -0,0 +1 @@ +export const DEFAULT_SEARCH_OUTPUT_CONTEXT_LINES = 2; diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/default-search-output-max-matches.constant.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/default-search-output-max-matches.constant.ts new file mode 100644 index 0000000000..6aa6a9cb9e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/default-search-output-max-matches.constant.ts @@ -0,0 +1 @@ +export const DEFAULT_SEARCH_OUTPUT_MAX_MATCHES = 10; diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/extract-json-paths-max-leaf-string-length.constant.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/extract-json-paths-max-leaf-string-length.constant.ts new file mode 100644 index 0000000000..9c43e88dae --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/extract-json-paths-max-leaf-string-length.constant.ts @@ -0,0 +1 @@ +export const EXTRACT_JSON_PATHS_MAX_LEAF_STRING_LENGTH = 1000; diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/max-inline-tool-output-bytes.constant.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/max-inline-tool-output-bytes.constant.ts new file mode 100644 index 0000000000..b31de0ded2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/max-inline-tool-output-bytes.constant.ts @@ -0,0 +1,4 @@ +// Shared budget for inlining a tool result. Above this, outputs are spilled to +// a file and navigated via the output +// navigation tools. ~16 KB is roughly 4k tokens (~4-5% of a 100k context window). +export const MAX_INLINE_TOOL_OUTPUT_BYTES = 16000; diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/output-navigation-tool-names.constant.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/output-navigation-tool-names.constant.ts new file mode 100644 index 0000000000..de2706edaf --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/output-navigation-tool-names.constant.ts @@ -0,0 +1,4 @@ +export const OUTPUT_NAVIGATION_TOOL_NAMES = [ + 'extract_json_paths', + 'search_output', +] as const; diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/search-output-max-line-length.constant.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/search-output-max-line-length.constant.ts new file mode 100644 index 0000000000..15a9414cde --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/constants/search-output-max-line-length.constant.ts @@ -0,0 +1 @@ +export const SEARCH_OUTPUT_MAX_LINE_LENGTH = 500; diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/extract-json-paths-tool.schema.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/extract-json-paths-tool.schema.ts new file mode 100644 index 0000000000..e860b96c51 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/extract-json-paths-tool.schema.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +export const ExtractJsonPathsInputZodSchema = z.object({ + fileId: z + .string() + .describe( + 'ID of the spilled output file to read (the outputRef.fileId returned when a tool result was too large to inline).', + ), + paths: z + .array(z.string().regex(/^\$/, 'Each path must start with "$"')) + .min(1) + .max(20) + .describe( + 'JSONPath-lite expressions to extract in a single pass over the file, e.g. ["$.failedStepLogs["step-id"].entries[0:5]", "$.status"]. Each supports dot notation, bracket keys, array index, slicing [start:end] and a single-level wildcard [*]. Filters and recursive descent are not supported (use code_interpreter for those). All paths share the same maxItems and maxDepth, and each is resolved independently so one failing path does not discard the others.', + ), + maxItems: z + .number() + .int() + .positive() + .max(200) + .optional() + .describe('Maximum array items to return per array (default 20).'), + maxDepth: z + .number() + .int() + .positive() + .max(10) + .optional() + .describe( + 'Maximum nesting depth before deeper structures are replaced with type markers (default 5).', + ), +}); diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/extract-json-paths-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/extract-json-paths-tool.ts new file mode 100644 index 0000000000..b1b58367aa --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/extract-json-paths-tool.ts @@ -0,0 +1,127 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { FileFolder } from 'twenty-shared/types'; + +import { FileService } from 'src/engine/core-modules/file/services/file.service'; +import { DEFAULT_EXTRACT_JSON_PATHS_MAX_DEPTH } from 'src/engine/core-modules/tool/tools/output-navigation-tool/constants/default-extract-json-paths-max-depth.constant'; +import { DEFAULT_EXTRACT_JSON_PATHS_MAX_ITEMS } from 'src/engine/core-modules/tool/tools/output-navigation-tool/constants/default-extract-json-paths-max-items.constant'; +import { ExtractJsonPathsInputZodSchema } from 'src/engine/core-modules/tool/tools/output-navigation-tool/extract-json-paths-tool.schema'; +import { + extractJsonPath, + JsonPathError, +} from 'src/engine/core-modules/tool/tools/output-navigation-tool/utils/extract-json-path.util'; +import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type'; +import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type'; +import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; +import { type Tool } from 'src/engine/core-modules/tool/types/tool.type'; + +type ExtractJsonPathsInput = { + fileId: string; + paths: string[]; + maxItems?: number; + maxDepth?: number; +}; + +type ExtractedPathResult = + | { path: string; value: unknown } + | { path: string; error: string }; + +@Injectable() +export class ExtractJsonPathsTool implements Tool { + private readonly logger = new Logger(ExtractJsonPathsTool.name); + + description = + 'Extract one or more sub-trees from a large spilled tool output (JSON) by path, without loading the whole file into context. Use the shape/skeleton returned with an outputRef to target paths. Reads and parses the file once, then resolves every path independently (a failing path does not discard the others). Each value is bounded by maxItems and maxDepth.'; + + inputSchema = ExtractJsonPathsInputZodSchema; + + constructor(private readonly fileService: FileService) {} + + async execute( + parameters: ToolInput, + context: ToolExecutionContext, + ): Promise { + const { workspaceId } = context; + const { fileId, paths, maxItems, maxDepth } = + parameters as ExtractJsonPathsInput; + + const effectiveMaxItems = maxItems ?? DEFAULT_EXTRACT_JSON_PATHS_MAX_ITEMS; + const effectiveMaxDepth = maxDepth ?? DEFAULT_EXTRACT_JSON_PATHS_MAX_DEPTH; + + let fileContent: { buffer: Buffer; mimeType: string } | null; + + try { + fileContent = await this.fileService.getFileContentById({ + fileId, + workspaceId, + fileFolder: FileFolder.AgentChat, + }); + } catch (error) { + this.logger.warn(`Failed to read file ${fileId}`, error); + + return { + success: false, + message: 'Failed to read output file', + error: error instanceof Error ? error.message : String(error), + }; + } + + if (fileContent === null) { + return { + success: false, + message: 'Output file not found', + error: `File "${fileId}" not found or no longer available.`, + }; + } + + let data: unknown; + + try { + data = JSON.parse(fileContent.buffer.toString('utf-8')); + } catch { + return { + success: false, + message: 'Output file is not valid JSON', + error: + 'The referenced file could not be parsed as JSON. Use search_output for free-text content.', + }; + } + + const results = paths.map((path): ExtractedPathResult => { + try { + const value = extractJsonPath({ + data, + path, + maxItems: effectiveMaxItems, + maxDepth: effectiveMaxDepth, + }); + + return { path, value }; + } catch (error) { + if (error instanceof JsonPathError) { + return { path, error: error.message }; + } + + throw error; + } + }); + + const resolvedCount = results.filter( + (result): result is { path: string; value: unknown } => 'value' in result, + ).length; + + if (resolvedCount === 0) { + return { + success: false, + message: 'Could not resolve any path', + result: { results }, + }; + } + + return { + success: true, + message: `Extracted ${resolvedCount}/${paths.length} path(s)`, + result: { results }, + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/search-output-tool.schema.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/search-output-tool.schema.ts new file mode 100644 index 0000000000..990494b3d2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/search-output-tool.schema.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +export const SearchOutputInputZodSchema = z.object({ + fileId: z + .string() + .describe( + 'ID of the spilled output file to search (the outputRef.fileId returned when a tool result was too large to inline).', + ), + pattern: z + .string() + .describe( + 'Text or regular expression to search for. Matched line by line against the indented JSON representation of the file.', + ), + maxMatches: z + .number() + .int() + .positive() + .max(100) + .optional() + .describe('Maximum number of matches to return (default 10).'), + offset: z + .number() + .int() + .nonnegative() + .optional() + .describe( + 'Number of matches to skip before returning results, for stateless pagination (default 0).', + ), + contextLines: z + .number() + .int() + .nonnegative() + .max(10) + .optional() + .describe( + 'Lines of context to include before and after each match (default 2).', + ), +}); diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/search-output-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/search-output-tool.ts new file mode 100644 index 0000000000..e056e765ae --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/search-output-tool.ts @@ -0,0 +1,96 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { FileFolder } from 'twenty-shared/types'; + +import { FileService } from 'src/engine/core-modules/file/services/file.service'; +import { DEFAULT_SEARCH_OUTPUT_CONTEXT_LINES } from 'src/engine/core-modules/tool/tools/output-navigation-tool/constants/default-search-output-context-lines.constant'; +import { DEFAULT_SEARCH_OUTPUT_MAX_MATCHES } from 'src/engine/core-modules/tool/tools/output-navigation-tool/constants/default-search-output-max-matches.constant'; +import { SearchOutputInputZodSchema } from 'src/engine/core-modules/tool/tools/output-navigation-tool/search-output-tool.schema'; +import { searchOutput } from 'src/engine/core-modules/tool/tools/output-navigation-tool/utils/search-output.util'; +import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type'; +import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type'; +import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; +import { type Tool } from 'src/engine/core-modules/tool/types/tool.type'; +import { isDefined } from 'twenty-shared/utils'; + +type SearchOutputInput = { + fileId: string; + pattern: string; + maxMatches?: number; + offset?: number; + contextLines?: number; +}; + +@Injectable() +export class SearchOutputTool implements Tool { + private readonly logger = new Logger(SearchOutputTool.name); + + description = + 'Search (grep-like) within a large spilled tool output for a text or regex pattern, returning matching lines with surrounding context. Supports stateless pagination via offset. Use this to locate an error message or key in a file too large to inline.'; + + inputSchema = SearchOutputInputZodSchema; + + constructor(private readonly fileService: FileService) {} + + async execute( + parameters: ToolInput, + context: ToolExecutionContext, + ): Promise { + const { workspaceId } = context; + const { fileId, pattern, maxMatches, offset, contextLines } = + parameters as SearchOutputInput; + + let fileContent: { buffer: Buffer; mimeType: string } | null; + + try { + fileContent = await this.fileService.getFileContentById({ + fileId, + workspaceId, + fileFolder: FileFolder.AgentChat, + }); + } catch (error) { + this.logger.warn(`Failed to read file ${fileId}`, error); + + return { + success: false, + message: 'Failed to read output file', + error: error instanceof Error ? error.message : String(error), + }; + } + + if (!isDefined(fileContent)) { + return { + success: false, + message: 'Output file not found', + error: `File "${fileId}" not found or no longer available.`, + }; + } + + const rawContent = fileContent.buffer.toString('utf-8'); + + let content: string; + + try { + content = JSON.stringify(JSON.parse(rawContent), null, 2); + } catch { + content = rawContent; + } + + const result = searchOutput({ + content, + pattern, + maxMatches: maxMatches ?? DEFAULT_SEARCH_OUTPUT_MAX_MATCHES, + offset: offset ?? 0, + contextLines: contextLines ?? DEFAULT_SEARCH_OUTPUT_CONTEXT_LINES, + }); + + return { + success: true, + message: + result.totalMatches === 0 + ? `No matches for "${pattern}"` + : `Found ${result.totalMatches} match(es) for "${pattern}"`, + result, + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/utils/__tests__/extract-json-path.util.spec.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/utils/__tests__/extract-json-path.util.spec.ts new file mode 100644 index 0000000000..55490e9d84 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/utils/__tests__/extract-json-path.util.spec.ts @@ -0,0 +1,157 @@ +import { + boundValue, + extractJsonPath, + JsonPathError, + parseJsonPath, +} from 'src/engine/core-modules/tool/tools/output-navigation-tool/utils/extract-json-path.util'; + +describe('parseJsonPath', () => { + it('returns no accessors for root', () => { + expect(parseJsonPath('$')).toEqual([]); + expect(parseJsonPath('')).toEqual([]); + }); + + it('parses dot notation', () => { + expect(parseJsonPath('$.steps.error')).toEqual([ + { type: 'key', key: 'steps' }, + { type: 'key', key: 'error' }, + ]); + }); + + it('parses bracket keys with dashes', () => { + expect(parseJsonPath('$.failedStepLogs["abc-123"]')).toEqual([ + { type: 'key', key: 'failedStepLogs' }, + { type: 'key', key: 'abc-123' }, + ]); + }); + + it('parses index and slice', () => { + expect(parseJsonPath('$.entries[2]')).toEqual([ + { type: 'key', key: 'entries' }, + { type: 'index', index: 2 }, + ]); + expect(parseJsonPath('$.entries[0:5]')).toEqual([ + { type: 'key', key: 'entries' }, + { type: 'slice', start: 0, end: 5 }, + ]); + expect(parseJsonPath('$.entries[-5:]')).toEqual([ + { type: 'key', key: 'entries' }, + { type: 'slice', start: -5, end: undefined }, + ]); + }); + + it('parses wildcard', () => { + expect(parseJsonPath('$.failedStepLogs[*].details')).toEqual([ + { type: 'key', key: 'failedStepLogs' }, + { type: 'wildcard' }, + { type: 'key', key: 'details' }, + ]); + }); + + it('throws when path does not start with $', () => { + expect(() => parseJsonPath('steps.error')).toThrow(JsonPathError); + }); + + it('throws on unexpected token', () => { + expect(() => parseJsonPath('$.steps#error')).toThrow(JsonPathError); + }); +}); + +describe('extractJsonPath', () => { + const data = { + id: 'run-1', + status: 'FAILED', + steps: [ + { id: 's1', status: 'SUCCESS' }, + { id: 's2', status: 'FAILED' }, + ], + failedStepLogs: { + s2: { entries: [1, 2, 3, 4, 5], details: { code: 'BOOM' } }, + }, + }; + + it('extracts a nested key', () => { + expect( + extractJsonPath({ data, path: '$.status', maxItems: 20, maxDepth: 5 }), + ).toBe('FAILED'); + }); + + it('extracts via bracket key and array index', () => { + expect( + extractJsonPath({ + data, + path: '$.steps[1].id', + maxItems: 20, + maxDepth: 5, + }), + ).toBe('s2'); + }); + + it('extracts an array slice', () => { + expect( + extractJsonPath({ + data, + path: '$.failedStepLogs["s2"].entries[0:2]', + maxItems: 20, + maxDepth: 5, + }), + ).toEqual([1, 2]); + }); + + it('extracts via wildcard over object values', () => { + expect( + extractJsonPath({ + data, + path: '$.failedStepLogs[*].details', + maxItems: 20, + maxDepth: 5, + }), + ).toEqual([{ code: 'BOOM' }]); + }); + + it('throws on missing key', () => { + expect(() => + extractJsonPath({ data, path: '$.nope', maxItems: 20, maxDepth: 5 }), + ).toThrow(JsonPathError); + }); + + it('throws on out-of-range index', () => { + expect(() => + extractJsonPath({ data, path: '$.steps[9]', maxItems: 20, maxDepth: 5 }), + ).toThrow(JsonPathError); + }); +}); + +describe('boundValue', () => { + it('truncates arrays beyond maxItems with a marker', () => { + const result = boundValue([1, 2, 3, 4, 5], 2, 5) as unknown[]; + + expect(result).toEqual([1, 2, '... (3 more items)']); + }); + + it('replaces structures deeper than maxDepth with type markers', () => { + const result = boundValue({ a: { b: { c: 1 } } }, 20, 2) as Record< + string, + unknown + >; + + expect(result).toEqual({ a: { b: 'object' } }); + }); + + it('marks deep arrays with their length', () => { + const result = boundValue({ a: { b: [1, 2, 3] } }, 20, 2) as Record< + string, + unknown + >; + + expect(result).toEqual({ a: { b: 'array[3]' } }); + }); + + it('truncates long string leaves', () => { + const longString = 'x'.repeat(2000); + const result = boundValue(longString, 20, 5) as string; + + expect(result).toContain('truncated, 2000 chars total'); + expect(result.length).toBeLessThan(longString.length); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/utils/__tests__/search-output.util.spec.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/utils/__tests__/search-output.util.spec.ts new file mode 100644 index 0000000000..3e3abcda26 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/utils/__tests__/search-output.util.spec.ts @@ -0,0 +1,96 @@ +import { searchOutput } from 'src/engine/core-modules/tool/tools/output-navigation-tool/utils/search-output.util'; + +const content = [ + 'line 0 ok', + 'line 1 error: boom', + 'line 2 ok', + 'line 3 error: kaboom', + 'line 4 ok', + 'line 5 error: splat', +].join('\n'); + +describe('searchOutput', () => { + it('returns matches with context and line numbers', () => { + const result = searchOutput({ + content, + pattern: 'error', + maxMatches: 10, + offset: 0, + contextLines: 1, + }); + + expect(result.totalMatches).toBe(3); + expect(result.hasMore).toBe(false); + expect(result.matches[0]).toEqual({ + lineNumber: 2, + match: 'line 1 error: boom', + context: '1: line 0 ok\n2: line 1 error: boom\n3: line 2 ok', + }); + }); + + it('caps results at maxMatches and reports hasMore', () => { + const result = searchOutput({ + content, + pattern: 'error', + maxMatches: 2, + offset: 0, + contextLines: 0, + }); + + expect(result.totalMatches).toBe(3); + expect(result.matches).toHaveLength(2); + expect(result.hasMore).toBe(true); + }); + + it('paginates with offset', () => { + const result = searchOutput({ + content, + pattern: 'error', + maxMatches: 2, + offset: 2, + contextLines: 0, + }); + + expect(result.totalMatches).toBe(3); + expect(result.matches).toHaveLength(1); + expect(result.matches[0].lineNumber).toBe(6); + expect(result.hasMore).toBe(false); + }); + + it('returns an empty result for zero matches', () => { + const result = searchOutput({ + content, + pattern: 'nonexistent', + maxMatches: 10, + offset: 0, + contextLines: 2, + }); + + expect(result).toEqual({ matches: [], totalMatches: 0, hasMore: false }); + }); + + it('supports regex patterns', () => { + const result = searchOutput({ + content, + pattern: 'splat|boom', + maxMatches: 10, + offset: 0, + contextLines: 0, + }); + + expect(result.totalMatches).toBe(3); + }); + + it('falls back to literal matching for invalid regex', () => { + const result = searchOutput({ + content: 'a (b c\nd e f', + pattern: '(b', + maxMatches: 10, + offset: 0, + contextLines: 0, + }); + + expect(result.totalMatches).toBe(1); + expect(result.matches[0].match).toBe('a (b c'); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/utils/extract-json-path.util.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/utils/extract-json-path.util.ts new file mode 100644 index 0000000000..a0d1326bf3 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/utils/extract-json-path.util.ts @@ -0,0 +1,206 @@ +import { isNull, isObject, isString } from '@sniptt/guards'; +import { EXTRACT_JSON_PATHS_MAX_LEAF_STRING_LENGTH } from 'src/engine/core-modules/tool/tools/output-navigation-tool/constants/extract-json-paths-max-leaf-string-length.constant'; +import { isDefined, isNonEmptyArray } from 'twenty-shared/utils'; + +export class JsonPathError extends Error {} + +type Accessor = + | { type: 'key'; key: string } + | { type: 'index'; index: number } + | { type: 'slice'; start?: number; end?: number } + | { type: 'wildcard' }; + +const KEY_DOT_REGEX = /^\.([a-zA-Z_$][a-zA-Z0-9_$]*)/; +const KEY_DOUBLE_QUOTE_REGEX = /^\["((?:[^"\\]|\\.)*)"\]/; +const KEY_SINGLE_QUOTE_REGEX = /^\['((?:[^'\\]|\\.)*)'\]/; +const WILDCARD_REGEX = /^\[\*\]/; +const SLICE_REGEX = /^\[(-?\d+)?:(-?\d+)?\]/; +const INDEX_REGEX = /^\[(-?\d+)\]/; + +const unescapeQuotedKey = (key: string): string => key.replace(/\\(.)/g, '$1'); + +const describeType = (value: unknown): string => { + if (isNull(value)) { + return 'null'; + } + + if (Array.isArray(value)) { + return 'array'; + } + + return typeof value; +}; + +export const parseJsonPath = (path: string): Accessor[] => { + let rest = path.trim(); + + if (rest === '' || rest === '$') { + return []; + } + + if (!rest.startsWith('$')) { + throw new JsonPathError('Path must start with "$"'); + } + + rest = rest.slice(1); + + const accessors: Accessor[] = []; + + while (rest.length > 0) { + let match: RegExpExecArray | null; + + if ((match = KEY_DOT_REGEX.exec(rest))) { + accessors.push({ type: 'key', key: match[1] }); + } else if ( + (match = KEY_DOUBLE_QUOTE_REGEX.exec(rest)) || + (match = KEY_SINGLE_QUOTE_REGEX.exec(rest)) + ) { + accessors.push({ type: 'key', key: unescapeQuotedKey(match[1]) }); + } else if ((match = WILDCARD_REGEX.exec(rest))) { + accessors.push({ type: 'wildcard' }); + } else if ((match = SLICE_REGEX.exec(rest))) { + accessors.push({ + type: 'slice', + start: isDefined(match[1]) ? Number(match[1]) : undefined, + end: isDefined(match[2]) ? Number(match[2]) : undefined, + }); + } else if ((match = INDEX_REGEX.exec(rest))) { + accessors.push({ type: 'index', index: Number(match[1]) }); + } else { + throw new JsonPathError(`Unexpected token in path near "${rest}"`); + } + + rest = rest.slice(match[0].length); + } + + return accessors; +}; + +const resolveAccessors = (value: unknown, accessors: Accessor[]): unknown => { + if (!isNonEmptyArray(accessors)) { + return value; + } + + const [head, ...rest] = accessors; + + switch (head.type) { + case 'key': { + if (isNull(value) || !isObject(value) || Array.isArray(value)) { + throw new JsonPathError( + `Cannot read key "${head.key}" on ${describeType(value)}`, + ); + } + + const record = value as Record; + + if (!Object.prototype.hasOwnProperty.call(record, head.key)) { + throw new JsonPathError(`Key "${head.key}" not found`); + } + + return resolveAccessors(record[head.key], rest); + } + case 'index': { + if (!Array.isArray(value)) { + throw new JsonPathError(`Cannot index ${describeType(value)}`); + } + + const index = head.index < 0 ? value.length + head.index : head.index; + + if (index < 0 || index >= value.length) { + throw new JsonPathError(`Index ${head.index} is out of range`); + } + + return resolveAccessors(value[index], rest); + } + case 'slice': { + if (!Array.isArray(value)) { + throw new JsonPathError(`Cannot slice ${describeType(value)}`); + } + + return resolveAccessors(value.slice(head.start, head.end), rest); + } + case 'wildcard': { + if (Array.isArray(value)) { + return value.map((child) => resolveAccessors(child, rest)); + } + + if (isObject(value)) { + return Object.values(value).map((child) => + resolveAccessors(child, rest), + ); + } + + throw new JsonPathError( + `Cannot apply wildcard to ${describeType(value)}`, + ); + } + } +}; + +const boundScalar = (value: unknown): unknown => { + if ( + isString(value) && + value.length > EXTRACT_JSON_PATHS_MAX_LEAF_STRING_LENGTH + ) { + return `${value.slice(0, EXTRACT_JSON_PATHS_MAX_LEAF_STRING_LENGTH)}… (truncated, ${value.length} chars total)`; + } + + return value; +}; + +export const boundValue = ( + value: unknown, + maxItems: number, + maxDepth: number, + depth = 0, +): unknown => { + if (isNull(value) || !isObject(value)) { + return boundScalar(value); + } + + if (depth >= maxDepth) { + return Array.isArray(value) ? `array[${value.length}]` : 'object'; + } + + if (Array.isArray(value)) { + const limited = value + .slice(0, maxItems) + .map((item) => boundValue(item, maxItems, maxDepth, depth + 1)); + + if (value.length > maxItems) { + limited.push(`... (${value.length - maxItems} more items)`); + } + + return limited; + } + + const entries = Object.entries(value); + const output: Record = {}; + + for (const [key, child] of entries.slice(0, maxItems)) { + output[key] = boundValue(child, maxItems, maxDepth, depth + 1); + } + + if (entries.length > maxItems) { + output['...'] = `(${entries.length - maxItems} more keys)`; + } + + return output; +}; + +export const extractJsonPath = ({ + data, + path, + maxItems, + maxDepth, +}: { + data: unknown; + path: string; + maxItems: number; + maxDepth: number; +}): unknown => { + const accessors = parseJsonPath(path); + const resolved = resolveAccessors(data, accessors); + + return boundValue(resolved, maxItems, maxDepth); +}; diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/utils/search-output.util.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/utils/search-output.util.ts new file mode 100644 index 0000000000..6bfafc3347 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/output-navigation-tool/utils/search-output.util.ts @@ -0,0 +1,80 @@ +import { SEARCH_OUTPUT_MAX_LINE_LENGTH } from 'src/engine/core-modules/tool/tools/output-navigation-tool/constants/search-output-max-line-length.constant'; + +export type SearchMatch = { + lineNumber: number; + match: string; + context: string; +}; + +export type SearchOutputResult = { + matches: SearchMatch[]; + totalMatches: number; + hasMore: boolean; +}; + +const escapeRegExp = (value: string): string => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const compilePattern = (pattern: string): RegExp => { + try { + return new RegExp(pattern); + } catch { + return new RegExp(escapeRegExp(pattern)); + } +}; + +const truncateLine = (line: string): string => + line.length > SEARCH_OUTPUT_MAX_LINE_LENGTH + ? `${line.slice(0, SEARCH_OUTPUT_MAX_LINE_LENGTH)}…` + : line; + +export const searchOutput = ({ + content, + pattern, + maxMatches, + offset, + contextLines, +}: { + content: string; + pattern: string; + maxMatches: number; + offset: number; + contextLines: number; +}): SearchOutputResult => { + const lines = content.split('\n'); + const regex = compilePattern(pattern); + + const matchLineIndices: number[] = []; + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + if (regex.test(lines[lineIndex])) { + matchLineIndices.push(lineIndex); + } + } + + const totalMatches = matchLineIndices.length; + const selected = matchLineIndices.slice(offset, offset + maxMatches); + + const matches: SearchMatch[] = selected.map((lineIndex) => { + const start = Math.max(0, lineIndex - contextLines); + const end = Math.min(lines.length - 1, lineIndex + contextLines); + + const contextBlock: string[] = []; + + for (let cursor = start; cursor <= end; cursor++) { + contextBlock.push(`${cursor + 1}: ${truncateLine(lines[cursor])}`); + } + + return { + lineNumber: lineIndex + 1, + match: truncateLine(lines[lineIndex]), + context: contextBlock.join('\n'), + }; + }); + + return { + matches, + totalMatches, + hasMore: offset + selected.length < totalMatches, + }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/tool/utils/__tests__/json-preview.util.spec.ts b/packages/twenty-server/src/engine/core-modules/tool/utils/__tests__/json-preview.util.spec.ts new file mode 100644 index 0000000000..c56511b458 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/utils/__tests__/json-preview.util.spec.ts @@ -0,0 +1,96 @@ +import { jsonPreview } from 'src/engine/core-modules/tool/utils/json-preview.util'; + +describe('jsonPreview', () => { + it('keeps concrete scalar values', () => { + expect( + jsonPreview({ id: 'abc', count: 3, active: true, deleted: null }), + ).toEqual({ id: 'abc', count: 3, active: true, deleted: null }); + }); + + it('keeps every object key so the schema is visible', () => { + const result = jsonPreview({ + a: 1, + b: 'two', + c: true, + d: null, + e: 5, + f: 6, + g: 7, + }) as Record; + + expect(Object.keys(result)).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g']); + }); + + it('limits arrays to the first items and notes the remaining count', () => { + const result = jsonPreview({ + items: Array.from({ length: 10 }, (_, index) => ({ id: index })), + }) as { items: unknown[] }; + + expect(result.items).toEqual([ + { id: 0 }, + { id: 1 }, + { id: 2 }, + '... (7 more items)', + ]); + }); + + it('collapses dynamic-key maps whose values share a shape', () => { + const failedStepLogs: Record = {}; + + for (let index = 0; index < 8; index++) { + failedStepLogs[`step-${index}`] = { reason: 'x' }; + } + + const result = jsonPreview({ failedStepLogs }) as { + failedStepLogs: Record; + }; + + const keys = Object.keys(result.failedStepLogs); + + expect(keys).toHaveLength(2); + expect(keys).toContain('step-0'); + expect(keys).toContain('... (7 more keys)'); + }); + + it('does not collapse maps whose values have differing shapes', () => { + const result = jsonPreview({ + a: { x: 1 }, + b: { y: 1 }, + c: { z: 1 }, + d: [1], + e: 'str', + f: 3, + }) as Record; + + expect(Object.keys(result)).toHaveLength(6); + }); + + it('truncates long leaf strings', () => { + const result = jsonPreview({ payload: 'a'.repeat(500) }) as { + payload: string; + }; + + expect(result.payload).toContain('truncated'); + expect(result.payload).toContain('total'); + }); + + it('keeps the serialized preview under the hard cap', () => { + const deeplyNested: Record = {}; + let cursor = deeplyNested; + + for (let index = 0; index < 20; index++) { + const child: Record = { + field0: 'value', + field1: 'value', + field2: 'value', + }; + + cursor[`level-${index}`] = child; + cursor = child; + } + + const result = jsonPreview(deeplyNested); + + expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThanOrEqual(2048); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/tool/utils/format-bytes.util.ts b/packages/twenty-server/src/engine/core-modules/tool/utils/format-bytes.util.ts new file mode 100644 index 0000000000..24e31d74e7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/utils/format-bytes.util.ts @@ -0,0 +1,2 @@ +export const formatBytes = (bytes: number): string => + bytes >= 1024 ? `${(bytes / 1024).toFixed(1)} kB` : `${bytes} B`; diff --git a/packages/twenty-server/src/engine/core-modules/tool/utils/json-preview.util.ts b/packages/twenty-server/src/engine/core-modules/tool/utils/json-preview.util.ts new file mode 100644 index 0000000000..ae3082c146 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/utils/json-preview.util.ts @@ -0,0 +1,110 @@ +import { isNull, isObject, isString } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; + +import { formatBytes } from 'src/engine/core-modules/tool/utils/format-bytes.util'; + +const PREVIEW_MAX_DEPTH = 4; +const PREVIEW_MAX_ARRAY_ITEMS = 3; +const PREVIEW_LEAF_STRING_LENGTH = 200; +const KEY_COLLAPSE_THRESHOLD = 5; +const PREVIEW_HARD_CAP_BYTES = 2048; + +const boundScalar = (value: unknown): unknown => { + if (isString(value)) { + const byteLength = Buffer.byteLength(value); + + if (value.length > PREVIEW_LEAF_STRING_LENGTH) { + return `${value.slice(0, PREVIEW_LEAF_STRING_LENGTH)}… (truncated, ${formatBytes(byteLength)} total)`; + } + } + + return value; +}; + +const shapeSignature = (value: unknown): string => { + if (Array.isArray(value)) { + return 'array'; + } + + if (isDefined(value) && isObject(value)) { + return `object:${Object.keys(value as Record) + .sort() + .join(',')}`; + } + + if (isNull(value)) { + return 'null'; + } + + return typeof value; +}; + +const buildPreview = ( + value: unknown, + maxDepth: number, + depth: number, +): unknown => { + if (isNull(value) || !isObject(value)) { + return boundScalar(value); + } + + if (depth >= maxDepth) { + return Array.isArray(value) ? `array[${value.length}]` : 'object'; + } + + if (Array.isArray(value)) { + const limited = value + .slice(0, PREVIEW_MAX_ARRAY_ITEMS) + .map((item) => buildPreview(item, maxDepth, depth + 1)); + + if (value.length > PREVIEW_MAX_ARRAY_ITEMS) { + limited.push( + `... (${value.length - PREVIEW_MAX_ARRAY_ITEMS} more items)`, + ); + } + + return limited; + } + + const entries = Object.entries(value as Record); + + if (entries.length > KEY_COLLAPSE_THRESHOLD) { + const signatures = new Set(entries.map(([, val]) => shapeSignature(val))); + + if (signatures.size === 1) { + const [representativeKey, representativeValue] = entries[0]; + + return { + [representativeKey]: buildPreview( + representativeValue, + maxDepth, + depth + 1, + ), + [`... (${entries.length - 1} more keys)`]: '...', + }; + } + } + + const output: Record = {}; + + for (const [key, child] of entries) { + output[key] = buildPreview(child, maxDepth, depth + 1); + } + + return output; +}; + +export const jsonPreview = (value: unknown): unknown => { + let maxDepth = PREVIEW_MAX_DEPTH; + let preview = buildPreview(value, maxDepth, 0); + + while ( + maxDepth > 1 && + Buffer.byteLength(JSON.stringify(preview) ?? '') > PREVIEW_HARD_CAP_BYTES + ) { + maxDepth -= 1; + preview = buildPreview(value, maxDepth, 0); + } + + return preview; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts index 4649b1568e..3035db0500 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts @@ -25,6 +25,7 @@ import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/servi import { estimateToolOutputTokens } from 'src/engine/core-modules/tool-provider/utils/estimate-tool-output-tokens.util'; import { getToolMetricName } from 'src/engine/core-modules/tool-provider/utils/get-tool-metric-name.util'; import { isToolOutputSuccessful } from 'src/engine/core-modules/tool-provider/utils/is-tool-output-successful.util'; +import { OUTPUT_NAVIGATION_TOOL_NAMES } from 'src/engine/core-modules/tool/tools/output-navigation-tool/constants/output-navigation-tool-names.constant'; import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { WORKFLOW_AGENT_REGISTRY_TOOL_CATEGORIES } from 'src/engine/metadata-modules/ai/ai-agent-execution/constants/workflow-agent-registry-tool-categories.const'; @@ -196,6 +197,7 @@ export class AgentAsyncExecutorService { toolProviderContext, { categories: WORKFLOW_AGENT_REGISTRY_TOOL_CATEGORIES, + excludeTools: [...OUTPUT_NAVIGATION_TOOL_NAMES], wrapWithErrorContext: false, }, ); 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 f0f684efbb..1d304825e8 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 @@ -152,7 +152,7 @@ export class ChatExecutionService { const preloadedTools = await this.toolRegistry.getToolsByName( AI_CHAT_TOOL_NAMES_TO_PRELOAD, toolContext, - { compactOutput: true }, + { compactOutput: true, spillLargeOutput: true }, ); const resolvedModelId = modelId ?? workspace.smartModel; @@ -204,7 +204,7 @@ export class ChatExecutionService { [EXECUTE_TOOL_TOOL_NAME]: createExecuteToolTool( this.toolRegistry, toolContext, - { compactOutput: true }, + { compactOutput: true, spillLargeOutput: true }, ), [LOAD_SKILL_TOOL_NAME]: createLoadSkillTool( (skillNames) => diff --git a/yarn.lock b/yarn.lock index 80af489728..449076c792 100644 --- a/yarn.lock +++ b/yarn.lock @@ -37389,6 +37389,16 @@ __metadata: languageName: node linkType: hard +"install-artifact-from-github@npm:^1.6.0": + version: 1.6.0 + resolution: "install-artifact-from-github@npm:1.6.0" + bin: + install-from-cache: bin/install-from-cache.js + save-to-github-cache: bin/save-to-github-cache.js + checksum: 10c0/e480c994a6230ba9c24b76e29908dcfb29977d3ee5b4a193b5c793139065aa0deb9bc5f475d11fd347c516b88ddb1a0f02935aa11fd1d3585761a4a4ccc1885c + languageName: node + linkType: hard + "internal-slot@npm:^1.1.0": version: 1.1.0 resolution: "internal-slot@npm:1.1.0" @@ -43238,6 +43248,15 @@ __metadata: languageName: node linkType: hard +"nan@npm:^2.27.0": + version: 2.27.0 + resolution: "nan@npm:2.27.0" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/38eb00b06e40f0c65b6a98d75795f17d651a8b7b52f03873dff6902d0053f12e7638d7f64fc52bda6c8f8ec454d69636e3988c8a9eb2bc749c2d5c255ba55f4c + languageName: node + linkType: hard + "nan@npm:^2.4.0": version: 2.26.2 resolution: "nan@npm:2.26.2" @@ -43768,7 +43787,7 @@ __metadata: languageName: node linkType: hard -"node-gyp@npm:latest": +"node-gyp@npm:^13.0.0, node-gyp@npm:latest": version: 13.0.0 resolution: "node-gyp@npm:13.0.0" dependencies: @@ -47520,6 +47539,17 @@ __metadata: languageName: node linkType: hard +"re2@npm:^1.25.0": + version: 1.25.0 + resolution: "re2@npm:1.25.0" + dependencies: + install-artifact-from-github: "npm:^1.6.0" + nan: "npm:^2.27.0" + node-gyp: "npm:^13.0.0" + checksum: 10c0/a1b89e4d6c594d3fe675fc18f5ee3ce06ea83d66ebc5b3b2d66abf8c93c57757478969d1c934db4379ffba0575154c149d4168a567d192a936fced369d7c3ecf + languageName: node + linkType: hard + "react-aria@npm:^3.48.0": version: 3.49.0 resolution: "react-aria@npm:3.49.0" @@ -54024,6 +54054,7 @@ __metadata: postal-mime: "npm:^2.6.1" prettier: "npm:^3.1.1" psl: "npm:^1.9.0" + re2: "npm:^1.25.0" react: "npm:^19.2.0" react-dom: "npm:^19.2.0" redis: "npm:^4.7.0"