Show tool execution messages in AI agent chat (#13117)

https://github.com/user-attachments/assets/c0a42726-50ac-496e-a993-9d6076a84a6a

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Abdul Rahman
2025-07-10 11:15:05 +05:30
committed by GitHub
parent e6cdae5c27
commit 8310b4ff01
62 changed files with 1304 additions and 227 deletions

View File

@ -62,11 +62,33 @@ export class AgentChatController {
@AuthUserWorkspaceId() userWorkspaceId: string,
@Res() res: Response,
) {
await this.agentStreamingService.streamAgentChat({
threadId: body.threadId,
userMessage: body.userMessage,
userWorkspaceId,
res,
});
try {
await this.agentStreamingService.streamAgentChat({
threadId: body.threadId,
userMessage: body.userMessage,
userWorkspaceId,
res,
});
} catch (error) {
// Handle errors at controller level for streaming responses
// since the RestApiExceptionFilter interferes with our streaming error handling
const errorMessage =
error instanceof Error ? error.message : 'Unknown error occurred';
if (!res.headersSent) {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Transfer-Encoding', 'chunked');
res.setHeader('Cache-Control', 'no-cache');
}
res.write(
JSON.stringify({
type: 'error',
message: errorMessage,
}) + '\n',
);
res.end();
}
}
}

View File

@ -13,8 +13,6 @@ import {
AgentExceptionCode,
} from 'src/engine/metadata-modules/agent/agent.exception';
import { AgentExecutionService } from './agent-execution.service';
@Injectable()
export class AgentChatService {
constructor(
@ -22,7 +20,6 @@ export class AgentChatService {
private readonly threadRepository: Repository<AgentChatThreadEntity>,
@InjectRepository(AgentChatMessageEntity, 'core')
private readonly messageRepository: Repository<AgentChatMessageEntity>,
private readonly agentExecutionService: AgentExecutionService,
) {}
async createThread(agentId: string, userWorkspaceId: string) {

View File

@ -10,7 +10,7 @@ import {
ModelId,
ModelProvider,
} from 'src/engine/core-modules/ai/constants/ai-models.const';
import { getAIModelById } from 'src/engine/core-modules/ai/utils/get-ai-model-by-id';
import { getEffectiveModelConfig } from 'src/engine/core-modules/ai/utils/get-effective-model-config.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import {
AgentChatMessageEntity,
@ -22,6 +22,7 @@ import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/agent/constant
import { convertOutputSchemaToZod } from 'src/engine/metadata-modules/agent/utils/convert-output-schema-to-zod';
import { OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
import { getAIModelById } from 'src/engine/core-modules/ai/utils/get-ai-model-by-id.util';
import { AgentEntity } from './agent.entity';
import { AgentException, AgentExceptionCode } from './agent.exception';
@ -45,12 +46,17 @@ export class AgentExecutionService {
private readonly agentToolService: AgentToolService,
@InjectRepository(AgentEntity, 'core')
private readonly agentRepository: Repository<AgentEntity>,
@InjectRepository(AgentChatMessageEntity, 'core')
private readonly agentChatmessageRepository: Repository<AgentChatMessageEntity>,
) {}
getModel = (modelId: ModelId, provider: ModelProvider) => {
switch (provider) {
case ModelProvider.NONE: {
const OpenAIProvider = createOpenAI({
apiKey: this.twentyConfigService.get('OPENAI_API_KEY'),
});
return OpenAIProvider(getEffectiveModelConfig(modelId).modelId);
}
case ModelProvider.OPENAI: {
const OpenAIProvider = createOpenAI({
apiKey: this.twentyConfigService.get('OPENAI_API_KEY'),
@ -77,6 +83,9 @@ export class AgentExecutionService {
let apiKey: string | undefined;
switch (provider) {
case ModelProvider.NONE:
apiKey = this.twentyConfigService.get('OPENAI_API_KEY');
break;
case ModelProvider.OPENAI:
apiKey = this.twentyConfigService.get('OPENAI_API_KEY');
break;
@ -91,7 +100,7 @@ export class AgentExecutionService {
}
if (!apiKey) {
throw new AgentException(
`${provider.toUpperCase()} API key not configured`,
`${provider === ModelProvider.NONE ? 'OPENAI' : provider.toUpperCase()} API key not configured`,
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
);
}

View File

@ -65,7 +65,7 @@ export class AgentStreamingService {
this.setupStreamingHeaders(res);
const { textStream } =
const { fullStream } =
await this.agentExecutionService.streamChatResponse({
agentId: thread.agent.id,
userMessage,
@ -74,9 +74,24 @@ export class AgentStreamingService {
let aiResponse = '';
for await (const chunk of textStream) {
aiResponse += chunk;
res.write(chunk);
for await (const chunk of fullStream) {
switch (chunk.type) {
case 'text-delta':
aiResponse += chunk.textDelta;
this.sendStreamEvent(res, {
type: chunk.type,
message: chunk.textDelta,
});
break;
case 'tool-call':
this.sendStreamEvent(res, {
type: chunk.type,
message: chunk.args?.toolDescription,
});
break;
default:
break;
}
}
await this.agentChatService.addMessage({
@ -90,10 +105,26 @@ export class AgentStreamingService {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error occurred';
return { success: false, error: errorMessage };
if (!res.headersSent) {
this.setupStreamingHeaders(res);
}
this.sendStreamEvent(res, {
type: 'error',
message: errorMessage,
});
res.end();
}
}
private sendStreamEvent(
res: Response,
event: { type: string; message: string },
): void {
res.write(JSON.stringify(event) + '\n');
}
private setupStreamingHeaders(res: Response): void {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Transfer-Encoding', 'chunked');

View File

@ -4,9 +4,9 @@ import { InjectRepository } from '@nestjs/typeorm';
import { ToolSet } from 'ai';
import { Repository } from 'typeorm';
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
@Injectable()
export class AgentToolService {

View File

@ -8,6 +8,7 @@ import {
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
@ -20,6 +21,7 @@ import { AgentChatThreadEntity } from './agent-chat-thread.entity';
@Entity('agent')
@Index('IDX_AGENT_ID_DELETED_AT', ['id', 'deletedAt'])
@Unique('IDX_AGENT_NAME_WORKSPACE_ID_UNIQUE', ['name', 'workspaceId'])
export class AgentEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@ -27,13 +29,19 @@ export class AgentEntity {
@Column({ nullable: false })
name: string;
@Column({ nullable: false })
label: string;
@Column({ nullable: true })
icon: string;
@Column({ nullable: true })
description: string;
@Column({ nullable: false, type: 'text' })
prompt: string;
@Column({ nullable: false, type: 'varchar' })
@Column({ nullable: false, type: 'varchar', default: 'auto' })
modelId: ModelId;
@Column({ nullable: true, type: 'jsonb' })
@ -42,6 +50,9 @@ export class AgentEntity {
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@Column({ default: false })
isCustom: boolean;
@ManyToOne(() => Workspace, (workspace) => workspace.agents, {
onDelete: 'CASCADE',
})

View File

@ -11,4 +11,5 @@ export enum AgentExceptionCode {
AGENT_NOT_FOUND = 'AGENT_NOT_FOUND',
AGENT_EXECUTION_FAILED = 'AGENT_EXECUTION_FAILED',
API_KEY_NOT_CONFIGURED = 'API_KEY_NOT_CONFIGURED',
USER_WORKSPACE_ID_NOT_FOUND = 'USER_WORKSPACE_ID_NOT_FOUND',
}

View File

@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { AgentChatService } from 'src/engine/metadata-modules/agent/agent-chat.service';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { AgentEntity } from './agent.entity';
@ -16,6 +17,7 @@ export class AgentService {
private readonly agentRepository: Repository<AgentEntity>,
@InjectRepository(RoleTargetsEntity, 'core')
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
private readonly agentChatService: AgentChatService,
) {}
async findManyAgents(workspaceId: string) {
@ -51,9 +53,35 @@ export class AgentService {
};
}
async createOneAgentAndFirstThread(
input: {
name: string;
label: string;
description?: string;
prompt: string;
modelId: ModelId;
},
workspaceId: string,
userWorkspaceId: string | null,
) {
const agent = await this.createOneAgent(input, workspaceId);
if (!userWorkspaceId) {
throw new AgentException(
'User workspace ID not found',
AgentExceptionCode.USER_WORKSPACE_ID_NOT_FOUND,
);
}
await this.agentChatService.createThread(agent.id, userWorkspaceId);
return agent;
}
async createOneAgent(
input: {
name: string;
label: string;
description?: string;
prompt: string;
modelId: ModelId;

View File

@ -10,14 +10,34 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat
import { convertObjectMetadataToSchemaProperties } from 'src/engine/utils/convert-object-metadata-to-schema-properties.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
export const getRecordInputSchema = (objectMetadata: ObjectMetadataEntity) => {
const createToolSchema = (
inputProperties: Record<string, JSONSchema7Definition>,
required?: string[],
) => {
return jsonSchema({
type: 'object',
properties: convertObjectMetadataToSchemaProperties({
properties: {
toolDescription: {
type: 'string',
description:
'A clear, human-readable description of the action being performed. Explain what operation you are executing and with what parameters in natural language.',
},
input: {
type: 'object',
properties: inputProperties,
...(required && { required }),
},
},
});
};
export const getRecordInputSchema = (objectMetadata: ObjectMetadataEntity) => {
return createToolSchema(
convertObjectMetadataToSchemaProperties({
item: objectMetadata,
forResponse: false,
}),
});
);
};
export const generateFindToolSchema = (
@ -48,10 +68,7 @@ export const generateFindToolSchema = (
}
});
return jsonSchema({
type: 'object',
properties: schemaProperties,
});
return createToolSchema(schemaProperties);
};
const generateFieldFilterJsonSchema = (
@ -808,25 +825,22 @@ const generateFieldFilterJsonSchema = (
};
export const generateBulkDeleteToolSchema = () => {
return jsonSchema({
type: 'object',
properties: {
filter: {
type: 'object',
description: 'Filter criteria to select records for bulk delete',
properties: {
id: {
type: 'object',
description: 'Filter to select records to delete',
properties: {
in: {
type: 'array',
items: {
type: 'string',
format: 'uuid',
},
description: 'Array of record IDs to delete',
return createToolSchema({
filter: {
type: 'object',
description: 'Filter criteria to select records for bulk delete',
properties: {
id: {
type: 'object',
description: 'Filter to select records to delete',
properties: {
in: {
type: 'array',
items: {
type: 'string',
format: 'uuid',
},
description: 'Array of record IDs to delete',
},
},
},
@ -834,3 +848,29 @@ export const generateBulkDeleteToolSchema = () => {
},
});
};
export const generateFindOneToolSchema = () => {
return createToolSchema(
{
id: {
type: 'string',
format: 'uuid',
description: 'The unique UUID of the record to retrieve',
},
},
['id'],
);
};
export const generateSoftDeleteToolSchema = () => {
return createToolSchema(
{
id: {
type: 'string',
format: 'uuid',
description: 'The unique UUID of the record to soft delete',
},
},
['id'],
);
};