Feat: Add reasoning summary and tool details display for AI responses (#14414)
Closes [#1405](https://github.com/twentyhq/core-team-issues/issues/1405) ### Implemented Features - Reasoning Summary: Collapsible display of AI thinking process with real-time streaming - Tool Execution: Detailed tool call rendering with expandable results and loading states - Error Handling: Clean error stream event display with proper formatting ### Demo https://github.com/user-attachments/assets/76ad29b7-d831-4bc8-a115-ad25f7ace6e2 <img width="504" height="819" alt="Screenshot 2025-09-12 at 3 23 39 AM" src="https://github.com/user-attachments/assets/82dc177d-f009-4e8a-8901-ee1a49201984" /> --------- Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
@@ -25,6 +25,7 @@ export interface AIModelConfig {
|
||||
provider: ModelProvider;
|
||||
inputCostPer1kTokensInCents: number;
|
||||
outputCostPer1kTokensInCents: number;
|
||||
doesSupportThinking?: boolean;
|
||||
}
|
||||
|
||||
export const AI_MODELS: AIModelConfig[] = [
|
||||
@@ -55,6 +56,7 @@ export const AI_MODELS: AIModelConfig[] = [
|
||||
provider: ModelProvider.ANTHROPIC,
|
||||
inputCostPer1kTokensInCents: 1.5,
|
||||
outputCostPer1kTokensInCents: 7.5,
|
||||
doesSupportThinking: true,
|
||||
},
|
||||
{
|
||||
modelId: 'claude-sonnet-4-20250514',
|
||||
@@ -62,6 +64,7 @@ export const AI_MODELS: AIModelConfig[] = [
|
||||
provider: ModelProvider.ANTHROPIC,
|
||||
inputCostPer1kTokensInCents: 0.3,
|
||||
outputCostPer1kTokensInCents: 1.5,
|
||||
doesSupportThinking: true,
|
||||
},
|
||||
{
|
||||
modelId: 'claude-3-5-haiku-20241022',
|
||||
@@ -69,6 +72,7 @@ export const AI_MODELS: AIModelConfig[] = [
|
||||
provider: ModelProvider.ANTHROPIC,
|
||||
inputCostPer1kTokensInCents: 0.08,
|
||||
outputCostPer1kTokensInCents: 0.4,
|
||||
doesSupportThinking: true,
|
||||
},
|
||||
{
|
||||
modelId: 'grok-3',
|
||||
|
||||
+12
-4
@@ -1,12 +1,12 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-adapter.service';
|
||||
import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
|
||||
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
|
||||
import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service';
|
||||
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
|
||||
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
|
||||
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
|
||||
const createMockToolRegistry = () => ({
|
||||
getAllToolTypes: jest.fn(),
|
||||
@@ -27,6 +27,8 @@ describe('ToolAdapterService', () => {
|
||||
|
||||
// Shared tools
|
||||
const unflaggedToolExecute = jest.fn(async (input: ToolInput) => ({
|
||||
success: true,
|
||||
message: 'Tool executed successfully',
|
||||
result: { echoed: input },
|
||||
}));
|
||||
const unflaggedTool: Tool = {
|
||||
@@ -36,6 +38,8 @@ describe('ToolAdapterService', () => {
|
||||
};
|
||||
|
||||
const flaggedToolExecute = jest.fn(async (input: ToolInput) => ({
|
||||
success: true,
|
||||
message: 'Tool executed successfully',
|
||||
result: { sent: input },
|
||||
}));
|
||||
const flaggedTool: Tool = {
|
||||
@@ -152,6 +156,10 @@ describe('ToolAdapterService', () => {
|
||||
|
||||
// Ensure wrapper forwards only parameters.input
|
||||
expect(unflaggedToolExecute).toHaveBeenCalledWith(input);
|
||||
expect(result).toEqual({ result: { echoed: input } });
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
message: 'Tool executed successfully',
|
||||
result: { echoed: input },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
|
||||
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
import { getMockObjectMetadataEntity } from 'src/utils/__test__/get-object-metadata-entity.mock';
|
||||
|
||||
@@ -147,7 +147,7 @@ describe('ToolService', () => {
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.record).toEqual(record);
|
||||
expect(result.result).toEqual(record);
|
||||
expect(ormManager.getRepositoryForWorkspace).toHaveBeenCalledWith(
|
||||
workspaceId,
|
||||
'testObject',
|
||||
@@ -189,8 +189,8 @@ describe('ToolService', () => {
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.records).toEqual(records);
|
||||
expect(result.count).toBe(2);
|
||||
expect(result.result.records).toEqual(records);
|
||||
expect(result.result.count).toBe(2);
|
||||
expect(mockRepo.find).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+2
@@ -16,6 +16,7 @@ export interface RegisteredAIModel {
|
||||
modelId: string;
|
||||
provider: ModelProvider;
|
||||
model: LanguageModel;
|
||||
doesSupportThinking?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -86,6 +87,7 @@ export class AiModelRegistryService {
|
||||
modelId: modelConfig.modelId,
|
||||
provider: ModelProvider.ANTHROPIC,
|
||||
model: anthropic(modelConfig.modelId),
|
||||
doesSupportThinking: modelConfig.doesSupportThinking,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -173,15 +173,17 @@ export class ToolService {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
records,
|
||||
count: records.length,
|
||||
message: `Found ${records.length} ${objectName} records`,
|
||||
result: {
|
||||
records,
|
||||
count: records.length,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to find ${objectName} records`,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -205,8 +207,8 @@ export class ToolService {
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required',
|
||||
message: `Failed to find ${objectName}: Record ID is required`,
|
||||
error: 'Record ID is required',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -217,21 +219,21 @@ export class ToolService {
|
||||
if (!record) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to find ${objectName}: Record with ID ${id} not found`,
|
||||
error: 'Record not found',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
record,
|
||||
message: `Found ${objectName} record`,
|
||||
result: record,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to find ${objectName} record`,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -261,8 +263,8 @@ export class ToolService {
|
||||
if (!objectMetadataItemWithFieldsMaps) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Object metadata not found',
|
||||
message: `Failed to create ${objectName}: Object metadata not found`,
|
||||
error: 'Object metadata not found',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -276,14 +278,14 @@ export class ToolService {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
record: createdRecord,
|
||||
message: `Successfully created ${objectName}`,
|
||||
result: createdRecord,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to create ${objectName}`,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -307,8 +309,8 @@ export class ToolService {
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required for update',
|
||||
message: `Failed to update ${objectName}: Record ID is required`,
|
||||
error: 'Record ID is required for update',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -319,8 +321,8 @@ export class ToolService {
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to update ${objectName}: Record with ID ${id} not found`,
|
||||
error: 'Record not found',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -335,8 +337,8 @@ export class ToolService {
|
||||
if (!objectMetadataItemWithFieldsMaps) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Object metadata not found',
|
||||
message: `Failed to update ${objectName}: Object metadata not found`,
|
||||
error: 'Object metadata not found',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -355,21 +357,21 @@ export class ToolService {
|
||||
if (!updatedRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to retrieve updated record',
|
||||
message: `Failed to update ${objectName}: Could not retrieve updated record`,
|
||||
error: 'Failed to retrieve updated record',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
record: updatedRecord,
|
||||
message: `Successfully updated ${objectName}`,
|
||||
result: updatedRecord,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to update ${objectName}`,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -393,8 +395,8 @@ export class ToolService {
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required for soft delete',
|
||||
message: `Failed to soft delete ${objectName}: Record ID is required`,
|
||||
error: 'Record ID is required for soft delete',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -405,8 +407,8 @@ export class ToolService {
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to soft delete ${objectName}: Record with ID ${id} not found`,
|
||||
error: 'Record not found',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -415,12 +417,13 @@ export class ToolService {
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully soft deleted ${objectName}`,
|
||||
result: { id },
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to soft delete ${objectName}`,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -444,8 +447,8 @@ export class ToolService {
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required for destroy',
|
||||
message: `Failed to destroy ${objectName}: Record ID is required`,
|
||||
error: 'Record ID is required for destroy',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -456,8 +459,8 @@ export class ToolService {
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to destroy ${objectName}: Record with ID ${id} not found`,
|
||||
error: 'Record not found',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -466,12 +469,13 @@ export class ToolService {
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully destroyed ${objectName}`,
|
||||
result: { id },
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to destroy ${objectName}`,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -495,8 +499,8 @@ export class ToolService {
|
||||
if (!filter || typeof filter !== 'object' || !('id' in filter)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Filter with record IDs is required for bulk soft delete',
|
||||
message: `Failed to soft delete many ${objectName}: Filter with record IDs is required`,
|
||||
error: 'Filter with record IDs is required for bulk soft delete',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -506,8 +510,8 @@ export class ToolService {
|
||||
if (!Array.isArray(recordIds) || recordIds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'At least one record ID is required for bulk soft delete',
|
||||
message: `Failed to soft delete many ${objectName}: At least one record ID is required`,
|
||||
error: 'At least one record ID is required for bulk soft delete',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -518,8 +522,8 @@ export class ToolService {
|
||||
if (existingRecords.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No records found to soft delete',
|
||||
message: `Failed to soft delete many ${objectName}: No records found with the provided IDs`,
|
||||
error: 'No records found to soft delete',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -527,14 +531,17 @@ export class ToolService {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: existingRecords.length,
|
||||
message: `Successfully soft deleted ${existingRecords.length} ${objectName} records`,
|
||||
result: {
|
||||
count: existingRecords.length,
|
||||
deletedIds: recordIds,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to soft delete many ${objectName}`,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -558,8 +565,8 @@ export class ToolService {
|
||||
if (!filter || typeof filter !== 'object' || !('id' in filter)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Filter with record IDs is required for bulk destroy',
|
||||
message: `Failed to destroy many ${objectName}: Filter with record IDs is required`,
|
||||
error: 'Filter with record IDs is required for bulk destroy',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -569,8 +576,8 @@ export class ToolService {
|
||||
if (!Array.isArray(recordIds) || recordIds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'At least one record ID is required for bulk destroy',
|
||||
message: `Failed to destroy many ${objectName}: At least one record ID is required`,
|
||||
error: 'At least one record ID is required for bulk destroy',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -581,8 +588,8 @@ export class ToolService {
|
||||
if (existingRecords.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No records found to destroy',
|
||||
message: `Failed to destroy many ${objectName}: No records found with the provided IDs`,
|
||||
error: 'No records found to destroy',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -590,14 +597,17 @@ export class ToolService {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: existingRecords.length,
|
||||
message: `Successfully destroyed ${existingRecords.length} ${objectName} records`,
|
||||
result: {
|
||||
count: existingRecords.length,
|
||||
destroyedIds: recordIds,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to destroy many ${objectName}`,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { basename, dirname, extname } from 'path';
|
||||
import { type Stream } from 'stream';
|
||||
import { type Readable } from 'stream';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { buildSignedPath } from 'twenty-shared/utils';
|
||||
@@ -28,7 +28,7 @@ export class FileService {
|
||||
folderPath: string,
|
||||
filename: string,
|
||||
workspaceId: string,
|
||||
): Promise<Stream> {
|
||||
): Promise<Readable> {
|
||||
const workspaceFolderPath = `workspace-${workspaceId}/${folderPath}`;
|
||||
|
||||
return await this.fileStorageService.read({
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ export const HttpRequestInputZodSchema = z.object({
|
||||
});
|
||||
|
||||
export const HttpToolParametersZodSchema = z.object({
|
||||
toolDescription: z
|
||||
loadingMessage: z
|
||||
.string()
|
||||
.describe(
|
||||
"A clear, human-readable status message describing the HTTP request being made. This will be shown to the user while the tool is being called, so phrase it as a present-tense status update (e.g., 'Making a GET request to ...'). Explain what endpoint you are calling and with what parameters in natural language.",
|
||||
|
||||
@@ -38,15 +38,23 @@ export class HttpTool implements Tool {
|
||||
|
||||
const response = await axios(axiosConfig);
|
||||
|
||||
return { result: response.data };
|
||||
return {
|
||||
success: true,
|
||||
message: `HTTP ${method} request to ${url} completed successfully`,
|
||||
result: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `HTTP ${method} request to ${url} failed`,
|
||||
error: error.response?.data || error.message || 'HTTP request failed',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `HTTP ${method} request to ${url} failed`,
|
||||
error: error instanceof Error ? error.message : 'HTTP request failed',
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ export const SendEmailInputZodSchema = z.object({
|
||||
});
|
||||
|
||||
export const SendEmailToolParametersZodSchema = z.object({
|
||||
toolDescription: z
|
||||
loadingMessage: z
|
||||
.string()
|
||||
.describe(
|
||||
"A clear, human-readable status message describing the email being sent. This will be shown to the user while the tool is being called, so phrase it as a present-tense status update (e.g., 'Sending email to customer about order status'). Explain what email you are sending and to whom in natural language.",
|
||||
|
||||
+9
-2
@@ -133,14 +133,19 @@ export class SendEmailTool implements Tool {
|
||||
this.logger.log(`Email sent successfully to ${email}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Email sent successfully to ${email}`,
|
||||
result: {
|
||||
success: true,
|
||||
message: `Email sent successfully to ${email}`,
|
||||
recipient: email,
|
||||
subject: safeSubject,
|
||||
connectedAccountId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof SendEmailToolException) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to send email to ${email}`,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
@@ -148,6 +153,8 @@ export class SendEmailTool implements Tool {
|
||||
this.logger.error(`Failed to send email: ${error}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to send email to ${email}`,
|
||||
error: error instanceof Error ? error.message : 'Failed to send email',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export type ToolOutput = {
|
||||
result?: unknown;
|
||||
success: boolean;
|
||||
message: string;
|
||||
error?: string;
|
||||
result?: unknown;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user