Replace agent handoff system with planning-based router (#16003)
## Overview This PR replaces the dynamic agent handoff system with a more predictable planning-based router that decides upfront how to handle multi-agent coordination. ## Major Changes ### 🔄 Architecture Shift: Handoffs → Planning **Removed:** - `AgentHandoffEntity` and handoff tracking system - `AgentHandoffService` and `AgentHandoffExecutorService` - Dynamic agent-to-agent transfers during execution - Handoff tool generation and description templates **Added:** - `AiRouterService` with two strategies: `simple` (single agent) and `planned` (multi-agent) - `AgentPlanExecutorService` for executing multi-step plans - Plan validation (cycle detection, dependency resolution) - `UnifiedRouterResult` type with discriminated union ### 🤖 New Standard Agents Added two new specialized agents: - **Researcher Agent**: Web search, fact-finding, competitive intelligence - **Code Agent**: TypeScript function generation for serverless workflows ### 🏗️ Router Refactoring (Latest) Split router responsibilities into focused services: - `AiRouterStrategyDeciderService`: Decides simple vs planned strategy - `AiRouterPlanGeneratorService`: Generates and validates execution plans - `AiRouterService`: Coordinates between services (reduced from 426→275 lines) ### ⚙️ Configuration Improvements - Added `outputStrategy` to agent definitions (`direct` vs `synthesize`) - Removed hardcoded special cases for workflow-builder - Added `plannerModel` field to workspace entity - Increased `MAX_STEPS` from 10 to 25 for complex workflows ### 📝 Agent Prompt Refinements Significantly simplified prompts for better clarity: - Workflow Builder: 51→36 lines - Helper: 49→28 lines - Data Manipulator: Enhanced with sorting guidance ### 🔍 Enhanced Debugging - Plan reasoning and step count in data message parts - Router debug info with token usage tracking - Better logging throughout execution pipeline ## Benefits 1. **Simpler Mental Model**: Router decides upfront vs dynamic transfers 2. **Better Predictability**: Users see the plan before execution 3. **Cleaner Architecture**: SRP with focused services 4. **Configuration Over Code**: Agent behavior via config, not hardcoded logic 5. **Plan Validation**: Catches invalid dependencies and cycles ## Migration Notes - Database migration removes `agentHandoff` table - Adds `plannerModel` column to workspace table - No API breaking changes (agent endpoints unchanged) ## Testing - Integration tests updated to remove handoff dependencies - Agent tool test utilities simplified - Plan validation covered by new logic ## Next Steps (Future PRs) - Parallel execution of independent plan steps - Dynamic re-planning based on results - Plan caching for common routing patterns - Error recovery strategies in plan executor
This commit is contained in:
-144
@@ -1,144 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { ProviderOptions } from '@ai-sdk/provider-utils';
|
||||
import {
|
||||
generateText,
|
||||
LanguageModel,
|
||||
ModelMessage,
|
||||
StopCondition,
|
||||
streamText,
|
||||
ToolSet,
|
||||
UIDataTypes,
|
||||
UIMessage,
|
||||
UITools,
|
||||
} from 'ai';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { AgentHandoffService } from './agent-handoff.service';
|
||||
import { AgentEntity } from './agent.entity';
|
||||
import { AgentException, AgentExceptionCode } from './agent.exception';
|
||||
|
||||
export type HandoffRequest = {
|
||||
fromAgentId: string;
|
||||
toAgentId: string;
|
||||
workspaceId: string;
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[];
|
||||
isStreaming?: boolean;
|
||||
};
|
||||
|
||||
export interface AgentExecutionContext {
|
||||
prepareAIRequestConfig: (params: {
|
||||
system: string;
|
||||
agent: AgentEntity | null;
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[];
|
||||
excludeHandoffTools?: boolean; // Prevent infinite recursion
|
||||
}) => Promise<{
|
||||
system: string;
|
||||
tools: ToolSet;
|
||||
model: LanguageModel;
|
||||
messages: ModelMessage[];
|
||||
stopWhen?: StopCondition<ToolSet>;
|
||||
providerOptions?: ProviderOptions;
|
||||
}>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AgentHandoffExecutorService {
|
||||
private readonly logger = new Logger(AgentHandoffExecutorService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AgentEntity)
|
||||
private readonly agentRepository: Repository<AgentEntity>,
|
||||
private readonly agentHandoffService: AgentHandoffService,
|
||||
) {}
|
||||
|
||||
async executeHandoff(
|
||||
handoffRequest: HandoffRequest,
|
||||
executionContext: AgentExecutionContext,
|
||||
) {
|
||||
try {
|
||||
const {
|
||||
fromAgentId,
|
||||
toAgentId,
|
||||
workspaceId,
|
||||
messages,
|
||||
isStreaming = false,
|
||||
} = handoffRequest;
|
||||
|
||||
const canHandoff = await this.agentHandoffService.canHandoffTo({
|
||||
fromAgentId,
|
||||
toAgentId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (!canHandoff) {
|
||||
throw new AgentException(
|
||||
`Agent ${fromAgentId} is not allowed to hand off to agent ${toAgentId}`,
|
||||
AgentExceptionCode.AGENT_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
const targetAgent = await this.agentRepository.findOne({
|
||||
where: { id: toAgentId, workspaceId },
|
||||
});
|
||||
|
||||
if (!targetAgent) {
|
||||
throw new AgentException(
|
||||
`Target agent ${toAgentId} not found`,
|
||||
AgentExceptionCode.AGENT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare AI request config using the execution context
|
||||
const aiRequestConfig = await executionContext.prepareAIRequestConfig({
|
||||
system: targetAgent.prompt,
|
||||
agent: targetAgent,
|
||||
messages,
|
||||
excludeHandoffTools: true, // Prevent infinite recursion
|
||||
});
|
||||
|
||||
if (isStreaming) {
|
||||
// Return stream for streaming contexts
|
||||
const stream = streamText(aiRequestConfig);
|
||||
|
||||
this.logger.log(`Started streaming handoff to agent ${toAgentId}`);
|
||||
|
||||
return stream;
|
||||
} else {
|
||||
// Use generateText for non-streaming contexts (workflows)
|
||||
const textResponse = await generateText(aiRequestConfig);
|
||||
|
||||
this.logger.log(
|
||||
`Successfully executed handoff to agent ${toAgentId} with response length: ${textResponse.text.length}`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully executed handoff to agent ${targetAgent.name}`,
|
||||
result: {
|
||||
response: textResponse.text,
|
||||
targetAgentName: targetAgent.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Handoff execution failed: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
|
||||
const { isStreaming = false, toAgentId } = handoffRequest;
|
||||
|
||||
if (isStreaming) {
|
||||
throw error; // Let streaming context handle the error
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to execute handoff to agent ${toAgentId}`,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type ToolSet } from 'ai';
|
||||
|
||||
import {
|
||||
AgentExecutionContext,
|
||||
AgentHandoffExecutorService,
|
||||
HandoffRequest,
|
||||
} from 'src/engine/metadata-modules/agent/agent-handoff-executor.service';
|
||||
import { AgentHandoffService } from 'src/engine/metadata-modules/agent/agent-handoff.service';
|
||||
import { AGENT_HANDOFF_DESCRIPTION_TEMPLATE } from 'src/engine/metadata-modules/agent/constants/agent-handoff-description.const';
|
||||
import { AGENT_HANDOFF_SCHEMA } from 'src/engine/metadata-modules/agent/constants/agent-handoff-schema.const';
|
||||
import { camelCase } from 'src/utils/camel-case';
|
||||
|
||||
@Injectable()
|
||||
export class AgentHandoffToolService {
|
||||
constructor(
|
||||
private readonly agentHandoffService: AgentHandoffService,
|
||||
private readonly agentHandoffExecutorService: AgentHandoffExecutorService,
|
||||
) {}
|
||||
|
||||
public async generateHandoffTools(
|
||||
agentId: string,
|
||||
workspaceId: string,
|
||||
executionContext: AgentExecutionContext,
|
||||
): Promise<ToolSet> {
|
||||
const handoffs = await this.agentHandoffService.getAgentHandoffs({
|
||||
fromAgentId: agentId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const handoffTools = handoffs.reduce<ToolSet>((tools, handoff) => {
|
||||
const toolName = `handoff_to_${camelCase(handoff.toAgent.name)}`;
|
||||
|
||||
tools[toolName] = {
|
||||
description:
|
||||
handoff.description ||
|
||||
handoff.toAgent.description ||
|
||||
AGENT_HANDOFF_DESCRIPTION_TEMPLATE.replace(
|
||||
'{agentName}',
|
||||
handoff.toAgent.name,
|
||||
),
|
||||
inputSchema: AGENT_HANDOFF_SCHEMA,
|
||||
execute: async ({ input }) => {
|
||||
const handoffRequest: HandoffRequest = {
|
||||
fromAgentId: agentId,
|
||||
toAgentId: handoff.toAgent.id,
|
||||
workspaceId,
|
||||
messages: input.messages,
|
||||
isStreaming: true, // Tools are executed during streaming
|
||||
};
|
||||
|
||||
return this.agentHandoffExecutorService.executeHandoff(
|
||||
handoffRequest,
|
||||
executionContext,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return tools;
|
||||
}, {});
|
||||
|
||||
return handoffTools;
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
|
||||
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
import { AgentEntity } from './agent.entity';
|
||||
|
||||
@Entity('agentHandoff')
|
||||
@Index('IDX_AGENT_HANDOFF_ID_DELETED_AT', ['id', 'deletedAt'])
|
||||
@Index(
|
||||
'IDX_AGENT_HANDOFF_FROM_TO_WORKSPACE_UNIQUE',
|
||||
['fromAgentId', 'toAgentId', 'workspaceId'],
|
||||
{
|
||||
unique: true,
|
||||
where: '"deletedAt" IS NULL',
|
||||
},
|
||||
)
|
||||
export class AgentHandoffEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
fromAgentId: string;
|
||||
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
toAgentId: string;
|
||||
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
workspaceId: string;
|
||||
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
description: string;
|
||||
|
||||
@ManyToOne(() => AgentEntity, (agent) => agent.outgoingHandoffs, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'fromAgentId' })
|
||||
fromAgent: Relation<AgentEntity>;
|
||||
|
||||
@ManyToOne(() => AgentEntity, (agent) => agent.incomingHandoffs, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'toAgentId' })
|
||||
toAgent: Relation<AgentEntity>;
|
||||
|
||||
@ManyToOne(() => WorkspaceEntity, (workspace) => workspace.agentHandoffs, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: Relation<WorkspaceEntity>;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@DeleteDateColumn({ type: 'timestamptz' })
|
||||
deletedAt?: Date;
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { AgentHandoffEntity } from './agent-handoff.entity';
|
||||
import { AgentEntity } from './agent.entity';
|
||||
import { AgentException, AgentExceptionCode } from './agent.exception';
|
||||
|
||||
@Injectable()
|
||||
export class AgentHandoffService {
|
||||
constructor(
|
||||
@InjectRepository(AgentEntity)
|
||||
private readonly agentRepository: Repository<AgentEntity>,
|
||||
@InjectRepository(AgentHandoffEntity)
|
||||
private readonly agentHandoffRepository: Repository<AgentHandoffEntity>,
|
||||
) {}
|
||||
|
||||
async canHandoffTo({
|
||||
fromAgentId,
|
||||
toAgentId,
|
||||
workspaceId,
|
||||
}: {
|
||||
fromAgentId: string;
|
||||
toAgentId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<boolean> {
|
||||
const handoff = await this.agentHandoffRepository.findOne({
|
||||
where: {
|
||||
fromAgentId,
|
||||
toAgentId,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
return Boolean(handoff);
|
||||
}
|
||||
|
||||
async getHandoffTargets({
|
||||
fromAgentId,
|
||||
workspaceId,
|
||||
}: {
|
||||
fromAgentId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<AgentEntity[]> {
|
||||
const handoffs = await this.agentHandoffRepository.find({
|
||||
where: {
|
||||
fromAgentId,
|
||||
workspaceId,
|
||||
},
|
||||
relations: ['toAgent'],
|
||||
});
|
||||
|
||||
return handoffs.map((handoff) => handoff.toAgent);
|
||||
}
|
||||
|
||||
async getAgentHandoffs({
|
||||
fromAgentId,
|
||||
workspaceId,
|
||||
}: {
|
||||
fromAgentId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<AgentHandoffEntity[]> {
|
||||
const handoffs = await this.agentHandoffRepository.find({
|
||||
where: {
|
||||
fromAgentId,
|
||||
workspaceId,
|
||||
},
|
||||
relations: ['toAgent'],
|
||||
});
|
||||
|
||||
return handoffs;
|
||||
}
|
||||
|
||||
async createHandoff({
|
||||
fromAgentId,
|
||||
toAgentId,
|
||||
workspaceId,
|
||||
description,
|
||||
}: {
|
||||
fromAgentId: string;
|
||||
toAgentId: string;
|
||||
workspaceId: string;
|
||||
description?: string;
|
||||
}): Promise<AgentHandoffEntity> {
|
||||
const [fromAgent, toAgent] = await Promise.all([
|
||||
this.agentRepository.findOne({
|
||||
where: { id: fromAgentId, workspaceId },
|
||||
}),
|
||||
this.agentRepository.findOne({
|
||||
where: { id: toAgentId, workspaceId },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!fromAgent) {
|
||||
throw new AgentException(
|
||||
`Agent with id ${fromAgentId} not found in workspace`,
|
||||
AgentExceptionCode.AGENT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (!toAgent) {
|
||||
throw new AgentException(
|
||||
`Agent with id ${toAgentId} not found in workspace`,
|
||||
AgentExceptionCode.AGENT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const existingHandoff = await this.agentHandoffRepository.findOne({
|
||||
where: {
|
||||
fromAgentId,
|
||||
toAgentId,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingHandoff) {
|
||||
throw new AgentException(
|
||||
`Handoff from ${fromAgent.name} to ${toAgent.name} already exists`,
|
||||
AgentExceptionCode.HANDOFF_ALREADY_EXISTS,
|
||||
);
|
||||
}
|
||||
|
||||
const handoff = await this.agentHandoffRepository.save({
|
||||
fromAgentId,
|
||||
toAgentId,
|
||||
workspaceId,
|
||||
description,
|
||||
});
|
||||
|
||||
return handoff;
|
||||
}
|
||||
|
||||
async removeHandoff({
|
||||
fromAgentId,
|
||||
toAgentId,
|
||||
workspaceId,
|
||||
}: {
|
||||
fromAgentId: string;
|
||||
toAgentId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<void> {
|
||||
await this.agentHandoffRepository.delete({
|
||||
fromAgentId,
|
||||
toAgentId,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
async getWorkspaceHandoffs(
|
||||
workspaceId: string,
|
||||
): Promise<AgentHandoffEntity[]> {
|
||||
return this.agentHandoffRepository.find({
|
||||
where: { workspaceId },
|
||||
relations: ['fromAgent', 'toAgent'],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { In, Repository } from 'typeorm';
|
||||
|
||||
import { AgentRoleService } from 'src/engine/metadata-modules/agent-role/agent-role.service';
|
||||
import { type CreateAgentInput } from 'src/engine/metadata-modules/agent/dtos/create-agent.input';
|
||||
import { type UpdateAgentInput } from 'src/engine/metadata-modules/agent/dtos/update-agent.input';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/compute-metadata-name-from-label.util';
|
||||
|
||||
import { AgentEntity } from './agent.entity';
|
||||
import { AgentException, AgentExceptionCode } from './agent.exception';
|
||||
|
||||
@Injectable()
|
||||
export class AgentService {
|
||||
constructor(
|
||||
@InjectRepository(AgentEntity)
|
||||
private readonly agentRepository: Repository<AgentEntity>,
|
||||
@InjectRepository(RoleTargetsEntity)
|
||||
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
|
||||
private readonly agentRoleService: AgentRoleService,
|
||||
) {}
|
||||
|
||||
async findManyAgents(workspaceId: string) {
|
||||
const agents = await this.agentRepository.find({
|
||||
where: { workspaceId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
if (agents.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const roleTargets = await this.roleTargetsRepository.find({
|
||||
where: {
|
||||
workspaceId,
|
||||
agentId: In(agents.map((agent) => agent.id)),
|
||||
},
|
||||
});
|
||||
|
||||
const agentRoleMap = new Map<string, string>();
|
||||
|
||||
roleTargets.forEach((roleTarget) => {
|
||||
if (roleTarget.agentId) {
|
||||
agentRoleMap.set(roleTarget.agentId, roleTarget.roleId);
|
||||
}
|
||||
});
|
||||
|
||||
return agents.map((agent) => ({
|
||||
...agent,
|
||||
roleId: agentRoleMap.get(agent.id) || null,
|
||||
}));
|
||||
}
|
||||
|
||||
async findOneByApplicationAndStandardId({
|
||||
applicationId,
|
||||
standardId,
|
||||
workspaceId,
|
||||
}: {
|
||||
applicationId: string;
|
||||
standardId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
return await this.agentRepository.findOne({
|
||||
where: { applicationId, standardId, workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async findOneAgent(id: string, workspaceId: string) {
|
||||
const agent = await this.agentRepository.findOne({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
throw new AgentException(
|
||||
`Agent with id ${id} not found`,
|
||||
AgentExceptionCode.AGENT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const roleTarget = await this.roleTargetsRepository.findOne({
|
||||
where: {
|
||||
agentId: id,
|
||||
workspaceId,
|
||||
},
|
||||
select: ['roleId'],
|
||||
});
|
||||
|
||||
return {
|
||||
...agent,
|
||||
roleId: roleTarget?.roleId || null,
|
||||
};
|
||||
}
|
||||
|
||||
async createOneAgent(
|
||||
input: CreateAgentInput & { isCustom: boolean },
|
||||
workspaceId: string,
|
||||
) {
|
||||
const agent = this.agentRepository.create({
|
||||
...input,
|
||||
name: input.name || computeMetadataNameFromLabel(input.label),
|
||||
workspaceId,
|
||||
isCustom: input.isCustom,
|
||||
});
|
||||
|
||||
const createdAgent = await this.agentRepository.save(agent);
|
||||
|
||||
if (input.roleId) {
|
||||
await this.agentRoleService.assignRoleToAgent({
|
||||
workspaceId,
|
||||
agentId: createdAgent.id,
|
||||
roleId: input.roleId,
|
||||
});
|
||||
}
|
||||
|
||||
return this.findOneAgent(createdAgent.id, workspaceId);
|
||||
}
|
||||
|
||||
async updateOneAgent(input: UpdateAgentInput, workspaceId: string) {
|
||||
const agent = await this.findOneAgent(input.id, workspaceId);
|
||||
|
||||
const updateData: Partial<AgentEntity> = {
|
||||
...agent,
|
||||
...Object.fromEntries(
|
||||
Object.entries(input).filter(([_, value]) => value !== undefined),
|
||||
),
|
||||
};
|
||||
|
||||
if (input.label !== undefined) {
|
||||
updateData.name = computeMetadataNameFromLabel(input.label);
|
||||
} else if (input.name !== undefined) {
|
||||
updateData.name = input.name;
|
||||
}
|
||||
|
||||
const updatedAgent = await this.agentRepository.save(updateData);
|
||||
|
||||
if (!('roleId' in input)) {
|
||||
return updatedAgent;
|
||||
}
|
||||
|
||||
if (input.roleId) {
|
||||
await this.agentRoleService.assignRoleToAgent({
|
||||
workspaceId,
|
||||
agentId: agent.id,
|
||||
roleId: input.roleId,
|
||||
});
|
||||
} else {
|
||||
await this.agentRoleService.removeRoleFromAgent({
|
||||
workspaceId,
|
||||
agentId: agent.id,
|
||||
});
|
||||
}
|
||||
|
||||
return this.findOneAgent(updatedAgent.id, workspaceId);
|
||||
}
|
||||
|
||||
async deleteOneAgent(id: string, workspaceId: string) {
|
||||
const agent = await this.findOneAgent(id, workspaceId);
|
||||
|
||||
await this.agentRepository.softDelete({ id: agent.id });
|
||||
|
||||
return agent;
|
||||
}
|
||||
}
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
export const AGENT_HANDOFF_DESCRIPTION_TEMPLATE =
|
||||
"Use this tool when the user's request requires {agentName}'s specialized expertise or capabilities. This will seamlessly consult with the specialist agent and provide you with their expert response to continue the conversation naturally. CRITICAL: You MUST call this tool function immediately. Do NOT respond with text about transferring - execute the tool instead. This is a FUNCTION CALL - you must invoke it, not describe it.";
|
||||
-99
@@ -1,99 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const AGENT_HANDOFF_SCHEMA = z.object({
|
||||
loadingMessage: z
|
||||
.string()
|
||||
.describe(
|
||||
'A brief, user-friendly message explaining what is happening while the handoff is being processed. This will be shown to the user during the handoff execution.',
|
||||
),
|
||||
input: z.object({
|
||||
messages: z
|
||||
.array(
|
||||
z.union([
|
||||
z.object({
|
||||
role: z.literal('system'),
|
||||
content: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
role: z.literal('user'),
|
||||
content: z.union([
|
||||
z.string(),
|
||||
z.array(
|
||||
z.union([
|
||||
z.object({
|
||||
type: z.literal('text'),
|
||||
text: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('image'),
|
||||
image: z
|
||||
.string()
|
||||
.describe('Base64 encoded image data or URL'),
|
||||
mediaType: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('file'),
|
||||
data: z
|
||||
.string()
|
||||
.describe('Base64 encoded file data or URL'),
|
||||
mediaType: z.string(),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
}),
|
||||
z.object({
|
||||
role: z.literal('assistant'),
|
||||
content: z.union([
|
||||
z.string(),
|
||||
z.array(
|
||||
z.union([
|
||||
z.object({
|
||||
type: z.literal('text'),
|
||||
text: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('file'),
|
||||
data: z
|
||||
.string()
|
||||
.describe('Base64 encoded file data or URL'),
|
||||
mediaType: z.string(),
|
||||
filename: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('reasoning'),
|
||||
text: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('tool-call'),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
input: z.record(z.string(), z.any()),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
}),
|
||||
z.object({
|
||||
role: z.literal('tool'),
|
||||
content: z.union([
|
||||
z.string(),
|
||||
z.array(
|
||||
z.object({
|
||||
type: z.literal('tool-result'),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
result: z.unknown(),
|
||||
isError: z.boolean().optional(),
|
||||
}),
|
||||
),
|
||||
]),
|
||||
toolCallId: z.string(),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.describe(
|
||||
'The conversation history to provide context to the specialist agent. Should include the latest user message/prompt and can include system, user, assistant, and tool messages with various content types.',
|
||||
),
|
||||
}),
|
||||
});
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
export const AGENT_SYSTEM_PROMPTS = {
|
||||
AGENT_EXECUTION: `You are an AI agent with access to various tools that will be provided to you dynamically. The available tools and their descriptions are passed to you through the tools property, so you should only use tools that are actually available to you.
|
||||
|
||||
TOOL USAGE GUIDELINES (applies to all tools):
|
||||
- Only use a tool if it is available and you have permission.
|
||||
- Always verify tool results and handle errors appropriately.
|
||||
- If a tool operation fails, explain the issue and suggest alternatives.
|
||||
- If you lack permission for a tool, respond: "I cannot perform this operation because I don't have the necessary permissions. Please check that I have been assigned the appropriate role for this workspace."
|
||||
|
||||
Your responsibilities:
|
||||
1. Analyze the input context and prompt carefully
|
||||
2. If a requested tool is not available, state the limitation as above
|
||||
3. If no tool operations are needed, process the request directly
|
||||
4. Provide comprehensive, structured responses for workflow consumption
|
||||
|
||||
Workflow context:
|
||||
- You are part of a larger workflow system; your output may be used by other nodes
|
||||
- Maintain consistency and reliability in your responses
|
||||
- Document any data or actions clearly
|
||||
|
||||
Important: After your response, the system will call generateObject to convert your output into a structured format. Ensure your response is comprehensive, logically structured, and includes all relevant data and tool results.`,
|
||||
|
||||
OUTPUT_GENERATOR: `You are a structured output generator for a workflow system. Your role is to convert the provided execution results into a structured format according to a specific schema.
|
||||
|
||||
Context: Before this call, the system executed generateText with tools to perform any required actions and gather information. The execution results you receive include both the AI agent's analysis and any tool outputs from database operations, HTTP requests, data retrieval, or other actions.
|
||||
|
||||
Your responsibilities:
|
||||
1. Analyze the execution results from the AI agent (including any tool outputs)
|
||||
2. Extract relevant information and data points from both text responses and tool results
|
||||
3. Structure the data according to the provided schema
|
||||
4. Ensure all required fields are populated with appropriate values
|
||||
5. Handle missing or unclear data gracefully by providing reasonable defaults or null values
|
||||
6. Maintain data integrity and consistency
|
||||
|
||||
Guidelines:
|
||||
- Focus on extracting and structuring the most relevant information
|
||||
- If the execution results contain tool outputs (including HTTP requests), incorporate that data appropriately
|
||||
- If certain schema fields cannot be populated from the results, use null or appropriate default values
|
||||
- Preserve the context and meaning from the original execution results
|
||||
- Ensure the output is clean, well-formatted, and ready for workflow consumption
|
||||
- Pay special attention to any data returned from tool executions (database queries, HTTP requests, record creation, etc.)`,
|
||||
|
||||
AGENT_CHAT: `You are a helpful AI assistant for this workspace. You can:
|
||||
- Answer questions about people, companies, opportunities, tasks, notes, and other business objects
|
||||
- Access and summarize information you have permission to see
|
||||
- Use tools provided to you dynamically when needed
|
||||
- Seamlessly consult with specialized agents when their expertise is better suited
|
||||
|
||||
Permissions:
|
||||
- Only perform actions and access data that your assigned role and permissions allow
|
||||
- If you lack permissions, politely explain the limitation
|
||||
- Only use tools that are actually available to you
|
||||
|
||||
Agent handoff (SEAMLESS CONSULTATION):
|
||||
- Use handoff tools when the user's request requires expertise outside your capabilities
|
||||
- IMPORTANT: Do not respond with text about transferring or consulting specialists
|
||||
- Execute the handoff tool function immediately when needed
|
||||
- Use the response returned by the specialist agent as your direct reply to the user
|
||||
- Present the specialist's expertise as if it's your own knowledge
|
||||
- Maintain a consistent voice and personality throughout the conversation
|
||||
- The user should never know that you consulted with another agent
|
||||
|
||||
When formatting responses:
|
||||
- Use markdown syntax to improve readability of long responses
|
||||
- Add appropriate headings, lists, bold/italic text where it enhances understanding
|
||||
- Include code blocks with proper language tags when showing code examples
|
||||
- Create tables when presenting structured data
|
||||
- Use blockquotes for important notes or callouts
|
||||
|
||||
Note: This base system prompt will be combined with the agent's specific instructions and context.`,
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
import { AgentDTO } from './agent.dto';
|
||||
|
||||
@ObjectType('AgentHandoff')
|
||||
export class AgentHandoffDTO {
|
||||
@Field(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Field(() => AgentDTO)
|
||||
toAgent: AgentDTO;
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@InputType()
|
||||
export class CreateAgentHandoffInput {
|
||||
@Field(() => UUIDScalarType)
|
||||
fromAgentId: string;
|
||||
|
||||
@Field(() => UUIDScalarType)
|
||||
toAgentId: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
description?: string;
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@InputType()
|
||||
export class RemoveAgentHandoffInput {
|
||||
@Field(() => UUIDScalarType)
|
||||
fromAgentId: string;
|
||||
|
||||
@Field(() => UUIDScalarType)
|
||||
toAgentId: string;
|
||||
}
|
||||
+8
-8
@@ -3,19 +3,19 @@ import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { type ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
import { type ModelId } from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
|
||||
import {
|
||||
AgentException,
|
||||
AgentExceptionCode,
|
||||
} from 'src/engine/metadata-modules/agent/agent.exception';
|
||||
} from 'src/engine/metadata-modules/ai-agent/agent.exception';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
|
||||
import { AgentRoleService } from './agent-role.service';
|
||||
import { AiAgentRoleService } from './ai-agent-role.service';
|
||||
|
||||
describe('AgentRoleService', () => {
|
||||
let service: AgentRoleService;
|
||||
describe('AiAgentRoleService', () => {
|
||||
let service: AiAgentRoleService;
|
||||
let agentRepository: Repository<AgentEntity>;
|
||||
let roleRepository: Repository<RoleEntity>;
|
||||
let roleTargetsRepository: Repository<RoleTargetsEntity>;
|
||||
@@ -28,7 +28,7 @@ describe('AgentRoleService', () => {
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AgentRoleService,
|
||||
AiAgentRoleService,
|
||||
{
|
||||
provide: getRepositoryToken(AgentEntity),
|
||||
useValue: {
|
||||
@@ -55,7 +55,7 @@ describe('AgentRoleService', () => {
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AgentRoleService>(AgentRoleService);
|
||||
service = module.get<AiAgentRoleService>(AiAgentRoleService);
|
||||
agentRepository = module.get<Repository<AgentEntity>>(
|
||||
getRepositoryToken(AgentEntity),
|
||||
);
|
||||
+5
-5
@@ -1,17 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
|
||||
import { AgentRoleService } from './agent-role.service';
|
||||
import { AiAgentRoleService } from './ai-agent-role.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AgentEntity, RoleEntity, RoleTargetsEntity]),
|
||||
],
|
||||
providers: [AgentRoleService],
|
||||
exports: [AgentRoleService],
|
||||
providers: [AiAgentRoleService],
|
||||
exports: [AiAgentRoleService],
|
||||
})
|
||||
export class AgentRoleModule {}
|
||||
export class AiAgentRoleModule {}
|
||||
+3
-3
@@ -3,16 +3,16 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { In, IsNull, Not, Repository } from 'typeorm';
|
||||
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
|
||||
import {
|
||||
AgentException,
|
||||
AgentExceptionCode,
|
||||
} from 'src/engine/metadata-modules/agent/agent.exception';
|
||||
} from 'src/engine/metadata-modules/ai-agent/agent.exception';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AgentRoleService {
|
||||
export class AiAgentRoleService {
|
||||
constructor(
|
||||
@InjectRepository(AgentEntity)
|
||||
private readonly agentRepository: Repository<AgentEntity>,
|
||||
-1
@@ -8,6 +8,5 @@ export enum AgentExceptionCode {
|
||||
API_KEY_NOT_CONFIGURED = 'API_KEY_NOT_CONFIGURED',
|
||||
USER_WORKSPACE_ID_NOT_FOUND = 'USER_WORKSPACE_ID_NOT_FOUND',
|
||||
ROLE_NOT_FOUND = 'ROLE_NOT_FOUND',
|
||||
HANDOFF_ALREADY_EXISTS = 'HANDOFF_ALREADY_EXISTS',
|
||||
ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS = 'ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS',
|
||||
}
|
||||
+2
-66
@@ -10,14 +10,10 @@ import {
|
||||
} from 'src/engine/guards/feature-flag.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { CreateAgentHandoffInput } from 'src/engine/metadata-modules/agent/dtos/create-agent-handoff.input';
|
||||
import { RemoveAgentHandoffInput } from 'src/engine/metadata-modules/agent/dtos/remove-agent-handoff.input';
|
||||
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
|
||||
|
||||
import { AgentHandoffService } from './agent-handoff.service';
|
||||
import { AgentService } from './agent.service';
|
||||
|
||||
import { AgentHandoffDTO } from './dtos/agent-handoff.dto';
|
||||
import { AgentIdInput } from './dtos/agent-id.input';
|
||||
import { AgentDTO } from './dtos/agent.dto';
|
||||
import { CreateAgentInput } from './dtos/create-agent.input';
|
||||
@@ -30,10 +26,7 @@ import { UpdateAgentInput } from './dtos/update-agent.input';
|
||||
)
|
||||
@Resolver()
|
||||
export class AgentResolver {
|
||||
constructor(
|
||||
private readonly agentService: AgentService,
|
||||
private readonly agentHandoffService: AgentHandoffService,
|
||||
) {}
|
||||
constructor(private readonly agentService: AgentService) {}
|
||||
|
||||
@Query(() => [AgentDTO])
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
@@ -47,31 +40,7 @@ export class AgentResolver {
|
||||
@Args('input') { id }: AgentIdInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
return this.agentService.findOneAgent(id, workspaceId);
|
||||
}
|
||||
|
||||
@Query(() => [AgentDTO])
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
async findAgentHandoffTargets(
|
||||
@Args('input') { id }: AgentIdInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
return this.agentHandoffService.getHandoffTargets({
|
||||
fromAgentId: id,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
@Query(() => [AgentHandoffDTO])
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
async findAgentHandoffs(
|
||||
@Args('input') { id }: AgentIdInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
return this.agentHandoffService.getAgentHandoffs({
|
||||
fromAgentId: id,
|
||||
workspaceId,
|
||||
});
|
||||
return this.agentService.findOneAgent(workspaceId, { id });
|
||||
}
|
||||
|
||||
@Mutation(() => AgentDTO)
|
||||
@@ -106,37 +75,4 @@ export class AgentResolver {
|
||||
) {
|
||||
return this.agentService.deleteOneAgent(id, workspaceId);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.AI_SETTINGS))
|
||||
async createAgentHandoff(
|
||||
@Args('input') input: CreateAgentHandoffInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
await this.agentHandoffService.createHandoff({
|
||||
fromAgentId: input.fromAgentId,
|
||||
toAgentId: input.toAgentId,
|
||||
workspaceId,
|
||||
description: input.description,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.AI_SETTINGS))
|
||||
async removeAgentHandoff(
|
||||
@Args('input') input: RemoveAgentHandoffInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
await this.agentHandoffService.removeHandoff({
|
||||
fromAgentId: input.fromAgentId,
|
||||
toAgentId: input.toAgentId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { In, Repository } from 'typeorm';
|
||||
|
||||
import { AiAgentRoleService } from 'src/engine/metadata-modules/ai-agent-role/ai-agent-role.service';
|
||||
import { type CreateAgentInput } from 'src/engine/metadata-modules/ai-agent/dtos/create-agent.input';
|
||||
import { type UpdateAgentInput } from 'src/engine/metadata-modules/ai-agent/dtos/update-agent.input';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/compute-metadata-name-from-label.util';
|
||||
|
||||
import { AgentException, AgentExceptionCode } from './agent.exception';
|
||||
|
||||
import { AgentEntity } from './entities/agent.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AgentService {
|
||||
constructor(
|
||||
@InjectRepository(AgentEntity)
|
||||
private readonly agentRepository: Repository<AgentEntity>,
|
||||
@InjectRepository(RoleTargetsEntity)
|
||||
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
|
||||
private readonly agentRoleService: AiAgentRoleService,
|
||||
) {}
|
||||
|
||||
async findManyAgents(workspaceId: string) {
|
||||
const agents = await this.agentRepository.find({
|
||||
where: { workspaceId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
if (agents.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const agentRoleMap = await this.buildAgentRoleMap(workspaceId, agents);
|
||||
|
||||
return agents.map((agent) => ({
|
||||
...agent,
|
||||
roleId: agentRoleMap.get(agent.id) || null,
|
||||
}));
|
||||
}
|
||||
|
||||
private async buildAgentRoleMap(
|
||||
workspaceId: string,
|
||||
agents: AgentEntity[],
|
||||
): Promise<Map<string, string>> {
|
||||
const roleTargets = await this.roleTargetsRepository.find({
|
||||
where: {
|
||||
workspaceId,
|
||||
agentId: In(agents.map((agent) => agent.id)),
|
||||
},
|
||||
});
|
||||
|
||||
const agentRoleMap = new Map<string, string>();
|
||||
|
||||
roleTargets.forEach((roleTarget) => {
|
||||
if (roleTarget.agentId) {
|
||||
agentRoleMap.set(roleTarget.agentId, roleTarget.roleId);
|
||||
}
|
||||
});
|
||||
|
||||
return agentRoleMap;
|
||||
}
|
||||
|
||||
async findOneByApplicationAndStandardId({
|
||||
applicationId,
|
||||
standardId,
|
||||
workspaceId,
|
||||
}: {
|
||||
applicationId: string;
|
||||
standardId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
return await this.agentRepository.findOne({
|
||||
where: { applicationId, standardId, workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async findOneAgent(
|
||||
workspaceId: string,
|
||||
{ id, name }: { id?: string; name?: string },
|
||||
) {
|
||||
this.validateAgentIdentifier(id, name);
|
||||
|
||||
const agent = await this.fetchAgent(workspaceId, id, name);
|
||||
const roleId = await this.fetchAgentRoleId(workspaceId, agent.id);
|
||||
|
||||
return {
|
||||
...agent,
|
||||
roleId,
|
||||
};
|
||||
}
|
||||
|
||||
private validateAgentIdentifier(
|
||||
id: string | undefined,
|
||||
name: string | undefined,
|
||||
): void {
|
||||
if (!isNonEmptyString(id) && !isNonEmptyString(name)) {
|
||||
throw new AgentException(
|
||||
'Either id or name must be provided',
|
||||
AgentExceptionCode.AGENT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (isNonEmptyString(id) && isNonEmptyString(name)) {
|
||||
throw new AgentException(
|
||||
'Cannot specify both id and name',
|
||||
AgentExceptionCode.AGENT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchAgent(
|
||||
workspaceId: string,
|
||||
id: string | undefined,
|
||||
name: string | undefined,
|
||||
): Promise<AgentEntity> {
|
||||
const agent = await this.agentRepository.findOne({
|
||||
where: id ? { id, workspaceId } : { name, workspaceId },
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
const identifier = id ? `id "${id}"` : `name "${name}"`;
|
||||
|
||||
throw new AgentException(
|
||||
`Agent with ${identifier} not found`,
|
||||
AgentExceptionCode.AGENT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
private async fetchAgentRoleId(
|
||||
workspaceId: string,
|
||||
agentId: string,
|
||||
): Promise<string | null> {
|
||||
const roleTarget = await this.roleTargetsRepository.findOne({
|
||||
where: {
|
||||
agentId,
|
||||
workspaceId,
|
||||
},
|
||||
select: ['roleId'],
|
||||
});
|
||||
|
||||
return roleTarget?.roleId || null;
|
||||
}
|
||||
|
||||
async createOneAgent(
|
||||
input: CreateAgentInput & { isCustom: boolean },
|
||||
workspaceId: string,
|
||||
) {
|
||||
const agent = this.buildNewAgent(input, workspaceId);
|
||||
const createdAgent = await this.agentRepository.save(agent);
|
||||
|
||||
if (isNonEmptyString(input.roleId)) {
|
||||
await this.assignRoleToNewAgent(
|
||||
workspaceId,
|
||||
createdAgent.id,
|
||||
input.roleId,
|
||||
);
|
||||
}
|
||||
|
||||
return this.findOneAgent(workspaceId, { id: createdAgent.id });
|
||||
}
|
||||
|
||||
private buildNewAgent(
|
||||
input: CreateAgentInput & { isCustom: boolean },
|
||||
workspaceId: string,
|
||||
): AgentEntity {
|
||||
return this.agentRepository.create({
|
||||
...input,
|
||||
name: isNonEmptyString(input.name)
|
||||
? input.name
|
||||
: computeMetadataNameFromLabel(input.label),
|
||||
workspaceId,
|
||||
isCustom: input.isCustom,
|
||||
});
|
||||
}
|
||||
|
||||
private async assignRoleToNewAgent(
|
||||
workspaceId: string,
|
||||
agentId: string,
|
||||
roleId: string,
|
||||
): Promise<void> {
|
||||
await this.agentRoleService.assignRoleToAgent({
|
||||
workspaceId,
|
||||
agentId,
|
||||
roleId,
|
||||
});
|
||||
}
|
||||
|
||||
async updateOneAgent(input: UpdateAgentInput, workspaceId: string) {
|
||||
const agent = await this.findOneAgent(workspaceId, { id: input.id });
|
||||
const updateData = this.buildUpdateData(agent, input);
|
||||
const updatedAgent = await this.agentRepository.save(updateData);
|
||||
|
||||
if (!('roleId' in input)) {
|
||||
return updatedAgent;
|
||||
}
|
||||
|
||||
await this.updateAgentRole(workspaceId, agent.id, input.roleId);
|
||||
|
||||
return this.findOneAgent(workspaceId, { id: updatedAgent.id });
|
||||
}
|
||||
|
||||
private buildUpdateData(
|
||||
agent: AgentEntity & { roleId: string | null },
|
||||
input: UpdateAgentInput,
|
||||
): Partial<AgentEntity> {
|
||||
const updateData: Partial<AgentEntity> = {
|
||||
...agent,
|
||||
...Object.fromEntries(
|
||||
Object.entries(input).filter(([_, value]) => value !== undefined),
|
||||
),
|
||||
};
|
||||
|
||||
if (input.label !== undefined) {
|
||||
updateData.name = computeMetadataNameFromLabel(input.label);
|
||||
} else if (input.name !== undefined) {
|
||||
updateData.name = input.name;
|
||||
}
|
||||
|
||||
return updateData;
|
||||
}
|
||||
|
||||
private async updateAgentRole(
|
||||
workspaceId: string,
|
||||
agentId: string,
|
||||
roleId: string | null | undefined,
|
||||
): Promise<void> {
|
||||
if (isNonEmptyString(roleId)) {
|
||||
await this.agentRoleService.assignRoleToAgent({
|
||||
workspaceId,
|
||||
agentId,
|
||||
roleId,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.agentRoleService.removeRoleFromAgent({
|
||||
workspaceId,
|
||||
agentId,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteOneAgent(id: string, workspaceId: string) {
|
||||
const agent = await this.findOneAgent(workspaceId, { id });
|
||||
|
||||
await this.agentRepository.softDelete({ id: agent.id });
|
||||
|
||||
return agent;
|
||||
}
|
||||
}
|
||||
+20
-54
@@ -1,21 +1,20 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
|
||||
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
|
||||
import { UserModule } from 'src/engine/core-modules/user/user.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||
import { AgentRoleModule } from 'src/engine/metadata-modules/agent-role/agent-role.module';
|
||||
import { AgentChatController } from 'src/engine/metadata-modules/agent/agent-chat.controller';
|
||||
import { UserModule } from 'src/engine/core-modules/user/user.module';
|
||||
import { AiAgentRoleModule } from 'src/engine/metadata-modules/ai-agent-role/ai-agent-role.module';
|
||||
import { AiBillingModule } from 'src/engine/metadata-modules/ai-billing/ai-billing.module';
|
||||
import { AiModelsModule } from 'src/engine/metadata-modules/ai-models/ai-models.module';
|
||||
import { AiRouterModule } from 'src/engine/metadata-modules/ai-router/ai-router.module';
|
||||
import { AiToolsModule } from 'src/engine/metadata-modules/ai-tools/ai-tools.module';
|
||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
|
||||
@@ -25,41 +24,23 @@ import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/wor
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
import { WorkflowToolsModule } from 'src/modules/workflow/workflow-tools/workflow-tools.module';
|
||||
|
||||
import { AgentChatMessagePartEntity } from './agent-chat-message-part.entity';
|
||||
import { AgentChatMessageEntity } from './agent-chat-message.entity';
|
||||
import { AgentChatThreadEntity } from './agent-chat-thread.entity';
|
||||
import { AgentChatResolver } from './agent-chat.resolver';
|
||||
import { AgentChatService } from './agent-chat.service';
|
||||
import { AgentExecutionService } from './agent-execution.service';
|
||||
import { AgentHandoffExecutorService } from './agent-handoff-executor.service';
|
||||
import { AgentHandoffToolService } from './agent-handoff-tool.service';
|
||||
import { AgentHandoffEntity } from './agent-handoff.entity';
|
||||
import { AgentHandoffService } from './agent-handoff.service';
|
||||
import { AgentModelConfigService } from './agent-model-config.service';
|
||||
import { AgentStreamingService } from './agent-streaming.service';
|
||||
import { AgentTitleGenerationService } from './agent-title-generation.service';
|
||||
import { AgentToolGeneratorService } from './agent-tool-generator.service';
|
||||
import { AgentEntity } from './agent.entity';
|
||||
import { AgentResolver } from './agent.resolver';
|
||||
import { AgentService } from './agent.service';
|
||||
|
||||
import { AgentEntity } from './entities/agent.entity';
|
||||
import { AgentActorContextService } from './services/agent-actor-context.service';
|
||||
|
||||
import { AgentExecutionService } from './services/agent-execution.service';
|
||||
import { AgentModelConfigService } from './services/agent-model-config.service';
|
||||
import { AgentPlanExecutorService } from './services/agent-plan-executor.service';
|
||||
import { AgentTitleGenerationService } from './services/agent-title-generation.service';
|
||||
import { AgentToolGeneratorService } from './services/agent-tool-generator.service';
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
AgentEntity,
|
||||
AgentHandoffEntity,
|
||||
RoleEntity,
|
||||
RoleTargetsEntity,
|
||||
AgentChatMessageEntity,
|
||||
AgentChatMessagePartEntity,
|
||||
AgentChatThreadEntity,
|
||||
FileEntity,
|
||||
UserWorkspaceEntity,
|
||||
]),
|
||||
AiModule,
|
||||
AgentRoleModule,
|
||||
TypeOrmModule.forFeature([AgentEntity, RoleEntity, RoleTargetsEntity]),
|
||||
AiModelsModule,
|
||||
AiToolsModule,
|
||||
AiBillingModule,
|
||||
AiAgentRoleModule,
|
||||
ThrottlerModule,
|
||||
AuditModule,
|
||||
FeatureFlagModule,
|
||||
@@ -77,38 +58,23 @@ import { AgentActorContextService } from './services/agent-actor-context.service
|
||||
UserWorkspaceModule,
|
||||
UserRoleModule,
|
||||
],
|
||||
controllers: [AgentChatController],
|
||||
providers: [
|
||||
AgentResolver,
|
||||
AgentChatResolver,
|
||||
AgentService,
|
||||
AgentExecutionService,
|
||||
AgentModelConfigService,
|
||||
AgentToolGeneratorService,
|
||||
AgentHandoffToolService,
|
||||
AgentChatService,
|
||||
AgentStreamingService,
|
||||
AgentPlanExecutorService,
|
||||
AgentTitleGenerationService,
|
||||
AgentHandoffExecutorService,
|
||||
AgentHandoffService,
|
||||
AgentActorContextService,
|
||||
],
|
||||
exports: [
|
||||
AgentService,
|
||||
AgentExecutionService,
|
||||
AgentToolGeneratorService,
|
||||
AgentHandoffToolService,
|
||||
AgentChatService,
|
||||
AgentStreamingService,
|
||||
AgentPlanExecutorService,
|
||||
AgentTitleGenerationService,
|
||||
TypeOrmModule.forFeature([
|
||||
AgentEntity,
|
||||
AgentChatMessageEntity,
|
||||
AgentChatMessagePartEntity,
|
||||
AgentChatThreadEntity,
|
||||
]),
|
||||
AgentHandoffExecutorService,
|
||||
AgentHandoffService,
|
||||
TypeOrmModule.forFeature([AgentEntity]),
|
||||
],
|
||||
})
|
||||
export class AgentModule {}
|
||||
export class AiAgentModule {}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
export const AGENT_CONFIG = {
|
||||
MAX_STEPS: 10,
|
||||
MAX_STEPS: 25,
|
||||
REASONING_BUDGET_TOKENS: 12000,
|
||||
};
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
export const AGENT_SYSTEM_PROMPTS = {
|
||||
BASE: `Tool usage strategy:
|
||||
- Chain multiple tools to solve complex tasks
|
||||
- If a tool fails, try alternative approaches
|
||||
- Use results from one tool to inform the next
|
||||
- Don't give up after first failure - be persistent
|
||||
- Validate assumptions before making changes
|
||||
|
||||
Error recovery:
|
||||
- Analyze error messages to understand what went wrong
|
||||
- Adjust parameters or try different tools
|
||||
- Only give up after exhausting reasonable alternatives
|
||||
|
||||
Permissions:
|
||||
- Only perform actions your role allows
|
||||
- Explain limitations if you lack permissions`,
|
||||
|
||||
CHAT_ADDITIONS: `
|
||||
Format responses with markdown for clarity (headings, lists, code blocks, tables).`,
|
||||
|
||||
WORKFLOW_ADDITIONS: `
|
||||
Context:
|
||||
- You are executing as part of a workflow automation
|
||||
- Your output may be used by downstream nodes
|
||||
- Be thorough and include all relevant data`,
|
||||
|
||||
ROUTER: (
|
||||
agentDescriptions: string,
|
||||
) => `You are an AI router that decides how to handle user messages.
|
||||
|
||||
Available agents:
|
||||
${agentDescriptions}
|
||||
|
||||
Decision process:
|
||||
1. Can ONE agent handle this entirely? → Use "simple" strategy
|
||||
2. Does it require MULTIPLE agents working together? → Use "planned" strategy
|
||||
|
||||
Agent selection rules (CRITICAL):
|
||||
- **data-manipulator**: For ALL database operations (create, read, update records) on companies, people, opportunities, tasks, notes, etc.
|
||||
- **helper**: ONLY for questions about HOW TO USE Twenty (features, setup, documentation)
|
||||
- **researcher**: For finding external information from the web
|
||||
- **workflow-builder**: For creating automation workflows
|
||||
|
||||
Use "planned" strategy when:
|
||||
- Request needs custom code AND context from data/research
|
||||
- Code generation requires knowing schemas, APIs, or external data
|
||||
- Multiple specialized capabilities must combine (code + data + research)
|
||||
|
||||
Use "simple" strategy for:
|
||||
- Single-agent tasks (data operations, research, documentation lookup)
|
||||
- Standard workflow creation (no custom code needed)
|
||||
|
||||
Examples:
|
||||
|
||||
Simple: "Show me all companies with >100 employees"
|
||||
→ { strategy: "simple", agentName: "data-manipulator", toolHints: { relevantObjects: ["company"], operations: ["find"] } }
|
||||
|
||||
Simple: "Create 30 companies in the automobile industry with 2 people each"
|
||||
→ { strategy: "simple", agentName: "data-manipulator", toolHints: { relevantObjects: ["company", "person"], operations: ["create"] } }
|
||||
|
||||
Simple: "Update all opportunities in stage 'Qualified' to 'Proposal'"
|
||||
→ { strategy: "simple", agentName: "data-manipulator", toolHints: { relevantObjects: ["opportunity"], operations: ["find", "update"] } }
|
||||
|
||||
Simple: "What's the latest news about AI trends?"
|
||||
→ { strategy: "simple", agentName: "researcher" }
|
||||
|
||||
Simple: "How do I set up email sync in Twenty?"
|
||||
→ { strategy: "simple", agentName: "helper" }
|
||||
|
||||
Simple: "Create a workflow that emails customers when deals close"
|
||||
→ { strategy: "simple", agentName: "workflow-builder" }
|
||||
|
||||
Planned: "Research information about Meta and update the company record"
|
||||
→ {
|
||||
strategy: "planned",
|
||||
plan: {
|
||||
steps: [
|
||||
{ stepNumber: 1, agentName: "researcher", task: "Look up current information about Meta (employee count, headquarters, revenue, etc.)", expectedOutput: "Company facts and data" },
|
||||
{ stepNumber: 2, agentName: "data-manipulator", task: "Update the Meta company record with the researched information", expectedOutput: "Updated company record", dependsOn: [1] }
|
||||
],
|
||||
reasoning: "Requires web research followed by database update"
|
||||
}
|
||||
}
|
||||
|
||||
For simple strategy toolHints:
|
||||
- relevantObjects: Extract object names (e.g., ["company", "person"])
|
||||
- operations: ["find", "create", "update", "delete"]
|
||||
|
||||
Keep plans minimal and only use planning when truly necessary.`,
|
||||
|
||||
OUTPUT_GENERATOR: `You are a structured output generator for a workflow system. Your role is to convert the provided execution results into a structured format according to a specific schema.
|
||||
|
||||
Context: Before this call, the system executed generateText with tools to perform any required actions and gather information. The execution results you receive include both the AI agent's analysis and any tool outputs from database operations, HTTP requests, data retrieval, or other actions.
|
||||
|
||||
Your responsibilities:
|
||||
1. Analyze the execution results from the AI agent (including any tool outputs)
|
||||
2. Extract relevant information and data points from both text responses and tool results
|
||||
3. Structure the data according to the provided schema
|
||||
4. Ensure all required fields are populated with appropriate values
|
||||
5. Handle missing or unclear data gracefully by providing reasonable defaults or null values
|
||||
6. Maintain data integrity and consistency
|
||||
|
||||
Guidelines:
|
||||
- Focus on extracting and structuring the most relevant information
|
||||
- If the execution results contain tool outputs (including HTTP requests), incorporate that data appropriately
|
||||
- If certain schema fields cannot be populated from the results, use null or appropriate default values
|
||||
- Preserve the context and meaning from the original execution results
|
||||
- Ensure the output is clean, well-formatted, and ready for workflow consumption
|
||||
- Pay special attention to any data returned from tool executions (database queries, HTTP requests, record creation, etc.)`,
|
||||
};
|
||||
+2
-2
@@ -10,8 +10,8 @@ import {
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
import { ModelConfiguration } from 'src/engine/metadata-modules/agent/types/modelConfiguration';
|
||||
import { ModelId } from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
|
||||
import { ModelConfiguration } from 'src/engine/metadata-modules/ai-agent/types/modelConfiguration';
|
||||
|
||||
@ObjectType('Agent')
|
||||
export class AgentDTO {
|
||||
+2
-2
@@ -10,8 +10,8 @@ import {
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
import { ModelConfiguration } from 'src/engine/metadata-modules/agent/types/modelConfiguration';
|
||||
import { ModelId } from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
|
||||
import { ModelConfiguration } from 'src/engine/metadata-modules/ai-agent/types/modelConfiguration';
|
||||
|
||||
@InputType()
|
||||
export class CreateAgentInput {
|
||||
+2
-2
@@ -10,8 +10,8 @@ import {
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
import { ModelConfiguration } from 'src/engine/metadata-modules/agent/types/modelConfiguration';
|
||||
import { ModelId } from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
|
||||
import { ModelConfiguration } from 'src/engine/metadata-modules/ai-agent/types/modelConfiguration';
|
||||
|
||||
@InputType()
|
||||
export class UpdateAgentInput {
|
||||
+7
-13
@@ -6,7 +6,6 @@ import {
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
@@ -14,12 +13,13 @@ import {
|
||||
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
|
||||
import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/interfaces/syncable-entity.interface';
|
||||
|
||||
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
import {
|
||||
ModelId,
|
||||
DEFAULT_SMART_MODEL,
|
||||
} from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AgentResponseFormat } from 'src/engine/metadata-modules/agent/types/agent-response-format.type';
|
||||
import { ModelConfiguration } from 'src/engine/metadata-modules/agent/types/modelConfiguration';
|
||||
|
||||
import { AgentHandoffEntity } from './agent-handoff.entity';
|
||||
import { AgentResponseFormat } from 'src/engine/metadata-modules/ai-agent/types/agent-response-format.type';
|
||||
import { ModelConfiguration } from 'src/engine/metadata-modules/ai-agent/types/modelConfiguration';
|
||||
|
||||
@Entity('agent')
|
||||
@Index('IDX_AGENT_ID_DELETED_AT', ['id', 'deletedAt'])
|
||||
@@ -52,7 +52,7 @@ export class AgentEntity
|
||||
@Column({ nullable: false, type: 'text' })
|
||||
prompt: string;
|
||||
|
||||
@Column({ nullable: false, type: 'varchar', default: 'auto' })
|
||||
@Column({ nullable: false, type: 'varchar', default: DEFAULT_SMART_MODEL })
|
||||
modelId: ModelId;
|
||||
|
||||
@Column({ nullable: true, type: 'jsonb', default: { type: 'text' } })
|
||||
@@ -70,12 +70,6 @@ export class AgentEntity
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: Relation<WorkspaceEntity>;
|
||||
|
||||
@OneToMany(() => AgentHandoffEntity, (handoff) => handoff.fromAgent)
|
||||
outgoingHandoffs: Relation<AgentHandoffEntity[]>;
|
||||
|
||||
@OneToMany(() => AgentHandoffEntity, (handoff) => handoff.toAgent)
|
||||
incomingHandoffs: Relation<AgentHandoffEntity[]>;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/use
|
||||
import {
|
||||
AgentException,
|
||||
AgentExceptionCode,
|
||||
} from 'src/engine/metadata-modules/agent/agent.exception';
|
||||
} from 'src/engine/metadata-modules/ai-agent/agent.exception';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
|
||||
+24
-38
@@ -15,29 +15,29 @@ import { getAppPath } from 'twenty-shared/utils';
|
||||
import { In } from 'typeorm';
|
||||
|
||||
import { getAllSelectableColumnNames } from 'src/engine/api/utils/get-all-selectable-column-names.utils';
|
||||
import { AI_TELEMETRY_CONFIG } from 'src/engine/core-modules/ai/constants/ai-telemetry.const';
|
||||
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
|
||||
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
|
||||
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai-models/constants/ai-telemetry.const';
|
||||
import { AIBillingService } from 'src/engine/metadata-modules/ai-billing/services/ai-billing.service';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
|
||||
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AgentHandoffToolService } from 'src/engine/metadata-modules/agent/agent-handoff-tool.service';
|
||||
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
|
||||
import { AGENT_CONFIG } from 'src/engine/metadata-modules/agent/constants/agent-config.const';
|
||||
import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/agent/constants/agent-system-prompts.const';
|
||||
import { AgentActorContextService } from 'src/engine/metadata-modules/agent/services/agent-actor-context.service';
|
||||
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/agent/types/recordIdsByObjectMetadataNameSingular.type';
|
||||
import { AgentService } from 'src/engine/metadata-modules/ai-agent/agent.service';
|
||||
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai-agent/constants/agent-config.const';
|
||||
import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai-agent/constants/agent-system-prompts.const';
|
||||
import { AgentActorContextService } from 'src/engine/metadata-modules/ai-agent/services/agent-actor-context.service';
|
||||
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai-agent/types/recordIdsByObjectMetadataNameSingular.type';
|
||||
import { type ToolHints } from 'src/engine/metadata-modules/ai-router/types/tool-hints.interface';
|
||||
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
|
||||
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
|
||||
import {
|
||||
AgentException,
|
||||
AgentExceptionCode,
|
||||
} from 'src/engine/metadata-modules/ai-agent/agent.exception';
|
||||
import { repairToolCall } from 'src/engine/metadata-modules/ai-agent/utils/repair-tool-call.util';
|
||||
|
||||
import { AgentExecutionContext } from './agent-handoff-executor.service';
|
||||
import { AgentModelConfigService } from './agent-model-config.service';
|
||||
import { AgentToolGeneratorService } from './agent-tool-generator.service';
|
||||
import { AgentEntity } from './agent.entity';
|
||||
import { AgentException, AgentExceptionCode } from './agent.exception';
|
||||
|
||||
import { repairToolCall } from './utils/repair-tool-call.util';
|
||||
import { AgentModelConfigService } from './agent-model-config.service';
|
||||
|
||||
export interface AgentExecutionResult {
|
||||
result: object;
|
||||
@@ -55,11 +55,10 @@ export interface StreamChatResponseResult {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AgentExecutionService implements AgentExecutionContext {
|
||||
export class AgentExecutionService {
|
||||
private readonly logger = new Logger(AgentExecutionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly agentHandoffToolService: AgentHandoffToolService,
|
||||
private readonly workspaceDomainsService: WorkspaceDomainsService,
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
|
||||
@@ -67,8 +66,8 @@ export class AgentExecutionService implements AgentExecutionContext {
|
||||
private readonly agentToolGeneratorService: AgentToolGeneratorService,
|
||||
private readonly agentModelConfigService: AgentModelConfigService,
|
||||
private readonly aiBillingService: AIBillingService,
|
||||
private readonly agentActorContextService: AgentActorContextService,
|
||||
private readonly agentService: AgentService,
|
||||
public readonly agentActorContextService: AgentActorContextService,
|
||||
public readonly agentService: AgentService,
|
||||
) {}
|
||||
|
||||
async prepareAIRequestConfig({
|
||||
@@ -77,7 +76,6 @@ export class AgentExecutionService implements AgentExecutionContext {
|
||||
agent,
|
||||
actorContext,
|
||||
roleIds,
|
||||
excludeHandoffTools = false,
|
||||
toolHints,
|
||||
}: {
|
||||
system: string;
|
||||
@@ -85,7 +83,6 @@ export class AgentExecutionService implements AgentExecutionContext {
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[];
|
||||
actorContext?: ActorMetadata;
|
||||
roleIds?: string[];
|
||||
excludeHandoffTools?: boolean;
|
||||
toolHints?: ToolHints;
|
||||
}) {
|
||||
try {
|
||||
@@ -111,24 +108,13 @@ export class AgentExecutionService implements AgentExecutionContext {
|
||||
toolHints,
|
||||
);
|
||||
|
||||
let handoffTools = {};
|
||||
|
||||
if (!excludeHandoffTools) {
|
||||
handoffTools =
|
||||
await this.agentHandoffToolService.generateHandoffTools(
|
||||
agent.id,
|
||||
agent.workspaceId,
|
||||
this, // Pass execution context
|
||||
);
|
||||
}
|
||||
|
||||
const nativeModelTools =
|
||||
this.agentModelConfigService.getNativeModelTools(
|
||||
registeredModel,
|
||||
agent,
|
||||
);
|
||||
|
||||
tools = { ...baseTools, ...handoffTools, ...nativeModelTools };
|
||||
tools = { ...baseTools, ...nativeModelTools };
|
||||
|
||||
providerOptions = this.agentModelConfigService.getProviderOptions(
|
||||
registeredModel,
|
||||
@@ -180,9 +166,6 @@ export class AgentExecutionService implements AgentExecutionContext {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetches and formats record data to provide context for AI agents
|
||||
// Respects permissions and field restrictions based on user role
|
||||
// Returns a JSON string with record data and workspace URLs
|
||||
async getContextForSystemPrompt(
|
||||
workspace: WorkspaceEntity,
|
||||
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType,
|
||||
@@ -309,7 +292,9 @@ export class AgentExecutionService implements AgentExecutionContext {
|
||||
};
|
||||
}> {
|
||||
try {
|
||||
const agent = await this.agentService.findOneAgent(agentId, workspace.id);
|
||||
const agent = await this.agentService.findOneAgent(workspace.id, {
|
||||
id: agentId,
|
||||
});
|
||||
|
||||
const contextBuildStart = Date.now();
|
||||
let contextPart = '';
|
||||
@@ -345,7 +330,7 @@ export class AgentExecutionService implements AgentExecutionContext {
|
||||
const aiRequestPrepStart = Date.now();
|
||||
|
||||
const aiRequestConfig = await this.prepareAIRequestConfig({
|
||||
system: `${AGENT_SYSTEM_PROMPTS.AGENT_CHAT}\n\n${agent.prompt}${contextString}`,
|
||||
system: `${AGENT_SYSTEM_PROMPTS.BASE}\n${AGENT_SYSTEM_PROMPTS.CHAT_ADDITIONS}\n\n${agent.prompt}${contextString}`,
|
||||
agent,
|
||||
messages,
|
||||
actorContext,
|
||||
@@ -372,6 +357,7 @@ export class AgentExecutionService implements AgentExecutionContext {
|
||||
model.modelId,
|
||||
usage,
|
||||
workspace.id,
|
||||
agent.id,
|
||||
);
|
||||
})
|
||||
.catch((usageError) => {
|
||||
+4
-5
@@ -5,11 +5,10 @@ import { openai } from '@ai-sdk/openai';
|
||||
import { ProviderOptions } from '@ai-sdk/provider-utils';
|
||||
import { ToolSet } from 'ai';
|
||||
|
||||
import { ModelProvider } from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
import { RegisteredAIModel } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
|
||||
import { AGENT_CONFIG } from 'src/engine/metadata-modules/agent/constants/agent-config.const';
|
||||
|
||||
import { AgentEntity } from './agent.entity';
|
||||
import { ModelProvider } from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
|
||||
import { RegisteredAIModel } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
|
||||
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai-agent/constants/agent-config.const';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AgentModelConfigService {
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai-agent/types/recordIdsByObjectMetadataNameSingular.type';
|
||||
import { type PlanStep } from 'src/engine/metadata-modules/ai-router/types/router-result.interface';
|
||||
import { standardAgentDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents';
|
||||
|
||||
import { AgentExecutionService } from './agent-execution.service';
|
||||
|
||||
export type PlanExecutionProgress = {
|
||||
type: 'plan-generated' | 'step-started' | 'step-completed';
|
||||
stepNumber?: number;
|
||||
agentName?: string;
|
||||
task?: string;
|
||||
output?: string;
|
||||
totalSteps?: number;
|
||||
reasoning?: string;
|
||||
};
|
||||
|
||||
export type StepResult = {
|
||||
stepNumber: number;
|
||||
agentName: string;
|
||||
output: string;
|
||||
};
|
||||
|
||||
export type PlanExecutionResult = {
|
||||
finalOutput: string;
|
||||
stepResults: StepResult[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AgentPlanExecutorService {
|
||||
private readonly logger = new Logger(AgentPlanExecutorService.name);
|
||||
|
||||
constructor(private readonly agentExecutionService: AgentExecutionService) {}
|
||||
|
||||
async executePlan({
|
||||
steps,
|
||||
reasoning,
|
||||
workspace,
|
||||
userWorkspaceId,
|
||||
recordIdsByObjectMetadataNameSingular,
|
||||
onProgress,
|
||||
writer,
|
||||
}: {
|
||||
steps: PlanStep[];
|
||||
reasoning: string;
|
||||
workspace: WorkspaceEntity;
|
||||
userWorkspaceId: string;
|
||||
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType;
|
||||
onProgress?: (progress: PlanExecutionProgress) => void;
|
||||
writer?: {
|
||||
write: (chunk: unknown) => void;
|
||||
merge: (stream: unknown) => void;
|
||||
};
|
||||
}): Promise<PlanExecutionResult> {
|
||||
this.logger.log(`Executing plan with ${steps.length} steps`);
|
||||
|
||||
onProgress?.({
|
||||
type: 'plan-generated',
|
||||
totalSteps: steps.length,
|
||||
reasoning,
|
||||
});
|
||||
|
||||
const stepResults: StepResult[] = [];
|
||||
|
||||
for (const step of steps) {
|
||||
try {
|
||||
this.logger.log(
|
||||
`[PLAN EXECUTION] Step ${step.stepNumber}: Looking up agent "${step.agentName}"`,
|
||||
);
|
||||
|
||||
const agent =
|
||||
await this.agentExecutionService.agentService.findOneAgent(
|
||||
workspace.id,
|
||||
{ name: step.agentName },
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`[PLAN EXECUTION] Step ${step.stepNumber}: Found agent "${agent.label}" (${agent.id})`,
|
||||
);
|
||||
|
||||
onProgress?.({
|
||||
type: 'step-started',
|
||||
stepNumber: step.stepNumber,
|
||||
agentName: step.agentName,
|
||||
task: step.task,
|
||||
});
|
||||
|
||||
const dependencyOutputs = this.gatherDependencyOutputs(
|
||||
step,
|
||||
stepResults,
|
||||
);
|
||||
|
||||
const promptWithContext = this.buildStepPrompt(step, dependencyOutputs);
|
||||
|
||||
const { stream: stepStream } =
|
||||
await this.agentExecutionService.streamChatResponse({
|
||||
workspace,
|
||||
agentId: agent.id,
|
||||
userWorkspaceId,
|
||||
messages: [
|
||||
{
|
||||
id: `step-${step.stepNumber}`,
|
||||
role: 'user' as const,
|
||||
parts: [{ type: 'text' as const, text: promptWithContext }],
|
||||
},
|
||||
],
|
||||
recordIdsByObjectMetadataNameSingular,
|
||||
});
|
||||
|
||||
let stepOutput = '';
|
||||
|
||||
if (writer) {
|
||||
writer.merge(
|
||||
stepStream.toUIMessageStream({
|
||||
onError: (error) => {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
},
|
||||
sendStart: false,
|
||||
onFinish: async ({ responseMessage }) => {
|
||||
stepOutput = responseMessage.parts
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => {
|
||||
if (part.type === 'text') {
|
||||
return part.text;
|
||||
}
|
||||
|
||||
return '';
|
||||
})
|
||||
.join('');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await stepStream.text;
|
||||
} else {
|
||||
stepOutput = await stepStream.text;
|
||||
}
|
||||
|
||||
stepResults.push({
|
||||
stepNumber: step.stepNumber,
|
||||
agentName: step.agentName,
|
||||
output: stepOutput,
|
||||
});
|
||||
|
||||
onProgress?.({
|
||||
type: 'step-completed',
|
||||
stepNumber: step.stepNumber,
|
||||
agentName: step.agentName,
|
||||
output: stepOutput,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Completed step ${step.stepNumber}: ${step.task.substring(0, 50)}...`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to execute step ${step.stepNumber}: ${step.task}`,
|
||||
error,
|
||||
);
|
||||
|
||||
throw new Error(
|
||||
`Plan execution failed at step ${step.stepNumber}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const finalOutput = this.synthesizeResults(stepResults, steps);
|
||||
|
||||
return {
|
||||
finalOutput,
|
||||
stepResults,
|
||||
};
|
||||
}
|
||||
|
||||
private gatherDependencyOutputs(
|
||||
step: PlanStep,
|
||||
previousResults: StepResult[],
|
||||
): string {
|
||||
if (!step.dependsOn || step.dependsOn.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const dependencyOutputs = step.dependsOn
|
||||
.map((depStepNum) => {
|
||||
const depResult = previousResults.find(
|
||||
(result) => result.stepNumber === depStepNum,
|
||||
);
|
||||
|
||||
if (!depResult) {
|
||||
throw new Error(
|
||||
`Dependency step ${depStepNum} not found for step ${step.stepNumber}`,
|
||||
);
|
||||
}
|
||||
|
||||
return `Step ${depStepNum} output:\n${depResult.output}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
return dependencyOutputs;
|
||||
}
|
||||
|
||||
private buildStepPrompt(step: PlanStep, dependencyOutputs: string): string {
|
||||
let prompt = `Task: ${step.task}\n\nExpected output: ${step.expectedOutput}`;
|
||||
|
||||
if (dependencyOutputs) {
|
||||
prompt += `\n\nPrevious step results:\n${dependencyOutputs}`;
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
private synthesizeResults(
|
||||
stepResults: StepResult[],
|
||||
steps: PlanStep[],
|
||||
): string {
|
||||
const lastStep = stepResults[stepResults.length - 1];
|
||||
|
||||
if (!lastStep) {
|
||||
return 'No results produced';
|
||||
}
|
||||
|
||||
const lastStepDefinition = steps.find(
|
||||
(s) => s.stepNumber === lastStep.stepNumber,
|
||||
);
|
||||
|
||||
if (lastStepDefinition) {
|
||||
const agentDefinition = standardAgentDefinitions.find(
|
||||
(def) => def.name === lastStepDefinition.agentName,
|
||||
);
|
||||
|
||||
if (agentDefinition?.outputStrategy === 'direct') {
|
||||
return lastStep.output;
|
||||
}
|
||||
}
|
||||
|
||||
const summary = stepResults
|
||||
.map((result) => {
|
||||
const step = steps.find((s) => s.stepNumber === result.stepNumber);
|
||||
|
||||
return `**Step ${result.stepNumber}: ${step?.task || 'Unknown task'}**\n${result.output}`;
|
||||
})
|
||||
.join('\n\n---\n\n');
|
||||
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -2,8 +2,8 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { generateText } from 'ai';
|
||||
|
||||
import { AI_TELEMETRY_CONFIG } from 'src/engine/core-modules/ai/constants/ai-telemetry.const';
|
||||
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
|
||||
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai-models/constants/ai-telemetry.const';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
|
||||
|
||||
@Injectable()
|
||||
export class AgentTitleGenerationService {
|
||||
+85
-7
@@ -6,15 +6,15 @@ import { Repository } from 'typeorm';
|
||||
|
||||
import type { ActorMetadata } from 'twenty-shared/types';
|
||||
|
||||
import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-adapter.service';
|
||||
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
|
||||
import { ToolAdapterService } from 'src/engine/metadata-modules/ai-tools/services/tool-adapter.service';
|
||||
import { ToolService } from 'src/engine/metadata-modules/ai-tools/services/tool.service';
|
||||
import { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
|
||||
import type { ToolHints } from 'src/engine/metadata-modules/ai-router/types/tool-hints.interface';
|
||||
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { HELPER_AGENT } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/helper-agent';
|
||||
import { WorkflowToolWorkspaceService as WorkflowToolService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service';
|
||||
import type { ToolHints } from 'src/engine/metadata-modules/ai-router/types/tool-hints.interface';
|
||||
|
||||
@Injectable()
|
||||
export class AgentToolGeneratorService {
|
||||
@@ -45,7 +45,7 @@ export class AgentToolGeneratorService {
|
||||
});
|
||||
|
||||
if (agent?.standardId === HELPER_AGENT.standardId) {
|
||||
return this.getHelperAgentTools();
|
||||
return this.wrapToolsWithErrorContext(this.getHelperAgentTools());
|
||||
}
|
||||
|
||||
const actionTools = await this.toolAdapterService.getTools();
|
||||
@@ -53,7 +53,7 @@ export class AgentToolGeneratorService {
|
||||
tools = { ...actionTools };
|
||||
|
||||
if (!roleIds) {
|
||||
return tools;
|
||||
return this.wrapToolsWithErrorContext(tools);
|
||||
}
|
||||
|
||||
const hasWorkflowPermission =
|
||||
@@ -93,7 +93,7 @@ export class AgentToolGeneratorService {
|
||||
);
|
||||
}
|
||||
|
||||
return tools;
|
||||
return this.wrapToolsWithErrorContext(tools);
|
||||
}
|
||||
|
||||
private getHelperAgentTools(): ToolSet {
|
||||
@@ -110,4 +110,82 @@ export class AgentToolGeneratorService {
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
private wrapToolsWithErrorContext(tools: ToolSet): ToolSet {
|
||||
const wrappedTools: ToolSet = {};
|
||||
|
||||
for (const [toolName, tool] of Object.entries(tools)) {
|
||||
if (!tool.execute) {
|
||||
wrappedTools[toolName] = tool;
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalExecute = tool.execute;
|
||||
|
||||
wrappedTools[toolName] = {
|
||||
...tool,
|
||||
execute: async (...args: Parameters<typeof originalExecute>) => {
|
||||
try {
|
||||
return await originalExecute(...args);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
message: errorMessage,
|
||||
tool: toolName,
|
||||
suggestion: this.generateErrorSuggestion(
|
||||
toolName,
|
||||
errorMessage,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return wrappedTools;
|
||||
}
|
||||
|
||||
private generateErrorSuggestion(
|
||||
toolName: string,
|
||||
errorMessage: string,
|
||||
): string {
|
||||
const lowerError = errorMessage.toLowerCase();
|
||||
|
||||
if (
|
||||
lowerError.includes('not found') ||
|
||||
lowerError.includes('does not exist')
|
||||
) {
|
||||
return 'Verify the ID or name exists with a search query first';
|
||||
}
|
||||
|
||||
if (
|
||||
lowerError.includes('permission') ||
|
||||
lowerError.includes('forbidden') ||
|
||||
lowerError.includes('unauthorized')
|
||||
) {
|
||||
return 'This operation requires elevated permissions or a different role';
|
||||
}
|
||||
|
||||
if (lowerError.includes('invalid') || lowerError.includes('validation')) {
|
||||
return 'Check the tool schema for valid parameter formats and types';
|
||||
}
|
||||
|
||||
if (
|
||||
lowerError.includes('duplicate') ||
|
||||
lowerError.includes('already exists')
|
||||
) {
|
||||
return 'A record with this identifier already exists. Try updating instead of creating';
|
||||
}
|
||||
|
||||
if (lowerError.includes('required') || lowerError.includes('missing')) {
|
||||
return 'Required fields are missing. Check which fields are mandatory for this operation';
|
||||
}
|
||||
|
||||
return 'Try adjusting the parameters or using a different approach';
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { STANDARD_OBJECT_IDS } from 'twenty-shared/metadata';
|
||||
|
||||
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/agent/utils/is-workflow-related-object.util';
|
||||
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai-agent/utils/is-workflow-related-object.util';
|
||||
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
|
||||
describe('isWorkflowRelatedObject', () => {
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { generateObject, type LanguageModel, NoSuchToolError } from 'ai';
|
||||
import { type z } from 'zod';
|
||||
|
||||
import { AI_TELEMETRY_CONFIG } from 'src/engine/core-modules/ai/constants/ai-telemetry.const';
|
||||
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai-models/constants/ai-telemetry.const';
|
||||
|
||||
type ToolCall = {
|
||||
type: 'tool-call';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AIBillingService } from 'src/engine/metadata-modules/ai-billing/services/ai-billing.service';
|
||||
import { AiModelsModule } from 'src/engine/metadata-modules/ai-models/ai-models.module';
|
||||
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
|
||||
|
||||
@Module({
|
||||
imports: [WorkspaceEventEmitterModule, AiModelsModule],
|
||||
providers: [AIBillingService],
|
||||
exports: [AIBillingService],
|
||||
})
|
||||
export class AiBillingModule {}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
// Configuration: $0.00001 = 1 credit
|
||||
export const DOLLAR_TO_CREDIT_MULTIPLIER = 1000000; // 1 / 0.000001 = 1000000 credits per dollar
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
|
||||
import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
|
||||
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
import { AIBillingService } from 'src/engine/metadata-modules/ai-billing/services/ai-billing.service';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
|
||||
|
||||
describe('AIBillingService', () => {
|
||||
let service: AIBillingService;
|
||||
let mockWorkspaceEventEmitter: jest.Mocked<WorkspaceEventEmitter>;
|
||||
|
||||
const mockTokenUsage = {
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
totalTokens: 1500,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockEventEmitterMethods = {
|
||||
emitCustomBatchEvent: jest.fn(),
|
||||
};
|
||||
|
||||
const mockAiModelRegistryMethods = {
|
||||
getEffectiveModelConfig: jest.fn().mockReturnValue({
|
||||
modelId: 'gpt-4o',
|
||||
label: 'GPT-4o',
|
||||
provider: 'openai',
|
||||
inputCostPer1kTokensInCents: 0.25,
|
||||
outputCostPer1kTokensInCents: 1.0,
|
||||
}),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AIBillingService,
|
||||
{
|
||||
provide: WorkspaceEventEmitter,
|
||||
useValue: mockEventEmitterMethods,
|
||||
},
|
||||
{
|
||||
provide: AiModelRegistryService,
|
||||
useValue: mockAiModelRegistryMethods,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AIBillingService>(AIBillingService);
|
||||
mockWorkspaceEventEmitter = module.get(WorkspaceEventEmitter);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('calculateCost', () => {
|
||||
it('should calculate cost correctly for valid model and token usage', async () => {
|
||||
const costInCents = await service.calculateCost('gpt-4o', mockTokenUsage);
|
||||
|
||||
// Expected: (1000/1000 * 0.25) + (500/1000 * 1.0) = 0.25 + 0.5 = 0.75 cents
|
||||
expect(costInCents).toBe(0.75);
|
||||
});
|
||||
|
||||
it('should calculate cost correctly with different token usage', async () => {
|
||||
const differentTokenUsage = {
|
||||
inputTokens: 2000,
|
||||
outputTokens: 1000,
|
||||
totalTokens: 3000,
|
||||
};
|
||||
|
||||
const costInCents = await service.calculateCost(
|
||||
'gpt-4o',
|
||||
differentTokenUsage,
|
||||
);
|
||||
|
||||
// Expected: (2000/1000 * 0.25) + (1000/1000 * 1.0) = 0.5 + 1.0 = 1.5 cents
|
||||
expect(costInCents).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateAndBillUsage', () => {
|
||||
it('should calculate cost and emit billing event when model exists', async () => {
|
||||
await service.calculateAndBillUsage(
|
||||
'gpt-4o',
|
||||
mockTokenUsage,
|
||||
'workspace-1',
|
||||
'agent-id-123',
|
||||
);
|
||||
|
||||
// Expected credits: (0.75 cents / 100) * 1000 = 0.0075 * 1000 = 7.5 credits, rounded to 8
|
||||
expect(
|
||||
mockWorkspaceEventEmitter.emitCustomBatchEvent,
|
||||
).toHaveBeenCalledWith(
|
||||
BILLING_FEATURE_USED,
|
||||
[
|
||||
{
|
||||
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
|
||||
value: 7500,
|
||||
dimensions: {
|
||||
execution_type: 'ai_token',
|
||||
resource_id: 'agent-id-123',
|
||||
execution_context_1: 'gpt-4o',
|
||||
},
|
||||
},
|
||||
],
|
||||
'workspace-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { LanguageModelUsage } from 'ai';
|
||||
|
||||
import { type ModelId } from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
|
||||
import { convertCentsToBillingCredits } from 'src/engine/metadata-modules/ai-billing/utils/convert-cents-to-billing-credits.util';
|
||||
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
|
||||
import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
|
||||
import { type BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type';
|
||||
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
|
||||
@Injectable()
|
||||
export class AIBillingService {
|
||||
private readonly logger = new Logger(AIBillingService.name);
|
||||
|
||||
constructor(
|
||||
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
) {}
|
||||
|
||||
async calculateCost(
|
||||
modelId: ModelId,
|
||||
usage: LanguageModelUsage,
|
||||
): Promise<number> {
|
||||
const model = this.aiModelRegistryService.getEffectiveModelConfig(modelId);
|
||||
|
||||
if (!model) {
|
||||
throw new Error(`AI model with id ${modelId} not found`);
|
||||
}
|
||||
|
||||
const inputCost =
|
||||
((usage.inputTokens ?? 0) / 1000) * model.inputCostPer1kTokensInCents;
|
||||
const outputCost =
|
||||
((usage.outputTokens ?? 0) / 1000) * model.outputCostPer1kTokensInCents;
|
||||
|
||||
const totalCost = inputCost + outputCost;
|
||||
|
||||
this.logger.log(
|
||||
`Calculated cost for model ${modelId}: ${totalCost} cents (input: ${inputCost}, output: ${outputCost})`,
|
||||
);
|
||||
|
||||
return totalCost;
|
||||
}
|
||||
|
||||
async calculateAndBillUsage(
|
||||
modelId: ModelId,
|
||||
usage: LanguageModelUsage,
|
||||
workspaceId: string,
|
||||
agentId?: string | null,
|
||||
): Promise<void> {
|
||||
const costInCents = await this.calculateCost(modelId, usage);
|
||||
const creditsUsed = Math.round(convertCentsToBillingCredits(costInCents));
|
||||
|
||||
this.sendAiTokenUsageEvent(workspaceId, creditsUsed, modelId, agentId);
|
||||
}
|
||||
|
||||
private sendAiTokenUsageEvent(
|
||||
workspaceId: string,
|
||||
creditsUsed: number,
|
||||
modelId: ModelId,
|
||||
agentId?: string | null,
|
||||
): void {
|
||||
this.workspaceEventEmitter.emitCustomBatchEvent<BillingUsageEvent>(
|
||||
BILLING_FEATURE_USED,
|
||||
[
|
||||
{
|
||||
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
|
||||
value: creditsUsed,
|
||||
dimensions: {
|
||||
execution_type: 'ai_token',
|
||||
resource_id: agentId || null,
|
||||
execution_context_1: modelId,
|
||||
},
|
||||
},
|
||||
],
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { DOLLAR_TO_CREDIT_MULTIPLIER } from 'src/engine/metadata-modules/ai-billing/constants/dollar-to-credit-multiplier';
|
||||
|
||||
// Converts cost in cents to cost in credits
|
||||
// Formula: credits = (cents / 100) * DOLLAR_TO_CREDIT_MULTIPLIER
|
||||
// Where DOLLAR_TO_CREDIT_MULTIPLIER = 1000000 (so $0.00001 = 1 credit)
|
||||
// Example: 1 cent = (1 / 100) * 1000000 = 10000 credits
|
||||
export const convertCentsToBillingCredits = (cents: number): number =>
|
||||
(cents / 100) * DOLLAR_TO_CREDIT_MULTIPLIER;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||
import { AiAgentModule } from 'src/engine/metadata-modules/ai-agent/ai-agent.module';
|
||||
import { AiBillingModule } from 'src/engine/metadata-modules/ai-billing/ai-billing.module';
|
||||
import { AiModelsModule } from 'src/engine/metadata-modules/ai-models/ai-models.module';
|
||||
import { AiRouterModule } from 'src/engine/metadata-modules/ai-router/ai-router.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
|
||||
import { AgentChatController } from './controllers/agent-chat.controller';
|
||||
import { AgentChatMessagePartEntity } from './entities/agent-chat-message-part.entity';
|
||||
import { AgentChatMessageEntity } from './entities/agent-chat-message.entity';
|
||||
import { AgentChatThreadEntity } from './entities/agent-chat-thread.entity';
|
||||
import { AgentChatResolver } from './resolvers/agent-chat.resolver';
|
||||
import { AgentChatService } from './services/agent-chat.service';
|
||||
import { AgentStreamingService } from './services/agent-streaming.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
AgentChatMessageEntity,
|
||||
AgentChatMessagePartEntity,
|
||||
AgentChatThreadEntity,
|
||||
FileEntity,
|
||||
UserWorkspaceEntity,
|
||||
]),
|
||||
AiModelsModule,
|
||||
AiBillingModule,
|
||||
AiRouterModule,
|
||||
AiAgentModule,
|
||||
ThrottlerModule,
|
||||
FeatureFlagModule,
|
||||
FileUploadModule,
|
||||
FileModule,
|
||||
PermissionsModule,
|
||||
WorkspacePermissionsCacheModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
TokenModule,
|
||||
UserWorkspaceModule,
|
||||
],
|
||||
controllers: [AgentChatController],
|
||||
providers: [AgentChatResolver, AgentChatService, AgentStreamingService],
|
||||
exports: [
|
||||
AgentChatService,
|
||||
AgentStreamingService,
|
||||
TypeOrmModule.forFeature([
|
||||
AgentChatMessageEntity,
|
||||
AgentChatMessagePartEntity,
|
||||
AgentChatThreadEntity,
|
||||
]),
|
||||
],
|
||||
})
|
||||
export class AiChatModule {}
|
||||
+3
-4
@@ -17,11 +17,10 @@ import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorat
|
||||
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/agent/types/recordIdsByObjectMetadataNameSingular.type';
|
||||
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai-agent/types/recordIdsByObjectMetadataNameSingular.type';
|
||||
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
|
||||
|
||||
import { AgentChatService } from './agent-chat.service';
|
||||
import { AgentStreamingService } from './agent-streaming.service';
|
||||
import { AgentChatService } from 'src/engine/metadata-modules/ai-chat/services/agent-chat.service';
|
||||
import { AgentStreamingService } from 'src/engine/metadata-modules/ai-chat/services/agent-streaming.service';
|
||||
|
||||
@Controller('rest/agent-chat')
|
||||
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard)
|
||||
+1
-1
@@ -10,7 +10,7 @@ import {
|
||||
Relation,
|
||||
} from 'typeorm';
|
||||
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/agent/agent-chat-thread.entity';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai-chat/entities/agent-chat-thread.entity';
|
||||
|
||||
import { AgentChatMessagePartEntity } from './agent-chat-message-part.entity';
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import {
|
||||
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
|
||||
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { AgentChatMessageEntity } from 'src/engine/metadata-modules/agent/agent-chat-message.entity';
|
||||
import { AgentChatMessageEntity } from 'src/engine/metadata-modules/ai-chat/entities/agent-chat-message.entity';
|
||||
|
||||
@Entity('agentChatThread')
|
||||
export class AgentChatThreadEntity {
|
||||
+3
-4
@@ -10,11 +10,10 @@ import {
|
||||
} from 'src/engine/guards/feature-flag.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { AgentChatService } from 'src/engine/metadata-modules/agent/agent-chat.service';
|
||||
import { AgentChatService } from 'src/engine/metadata-modules/ai-chat/services/agent-chat.service';
|
||||
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
|
||||
|
||||
import { AgentChatMessageDTO } from './dtos/agent-chat-message.dto';
|
||||
import { AgentChatThreadDTO } from './dtos/agent-chat-thread.dto';
|
||||
import { AgentChatMessageDTO } from 'src/engine/metadata-modules/ai-chat/dtos/agent-chat-message.dto';
|
||||
import { AgentChatThreadDTO } from 'src/engine/metadata-modules/ai-chat/dtos/agent-chat-thread.dto';
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
+13
-12
@@ -6,19 +6,18 @@ import { Repository } from 'typeorm';
|
||||
|
||||
import type { UIDataTypes, UIMessagePart, UITools } from 'ai';
|
||||
|
||||
import { AgentChatMessagePartEntity } from 'src/engine/metadata-modules/agent/agent-chat-message-part.entity';
|
||||
import { AgentChatMessagePartEntity } from 'src/engine/metadata-modules/ai-chat/entities/agent-chat-message-part.entity';
|
||||
import {
|
||||
AgentChatMessageEntity,
|
||||
AgentChatMessageRole,
|
||||
} from 'src/engine/metadata-modules/agent/agent-chat-message.entity';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/agent/agent-chat-thread.entity';
|
||||
} from 'src/engine/metadata-modules/ai-chat/entities/agent-chat-message.entity';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai-chat/entities/agent-chat-thread.entity';
|
||||
import {
|
||||
AgentException,
|
||||
AgentExceptionCode,
|
||||
} from 'src/engine/metadata-modules/agent/agent.exception';
|
||||
import { mapUIMessagePartsToDBParts } from 'src/engine/metadata-modules/agent/utils/mapUIMessagePartsToDBParts';
|
||||
|
||||
import { AgentTitleGenerationService } from './agent-title-generation.service';
|
||||
} from 'src/engine/metadata-modules/ai-agent/agent.exception';
|
||||
import { mapUIMessagePartsToDBParts } from 'src/engine/metadata-modules/ai-chat/utils/mapUIMessagePartsToDBParts';
|
||||
import { AgentTitleGenerationService } from 'src/engine/metadata-modules/ai-agent/services/agent-title-generation.service';
|
||||
|
||||
@Injectable()
|
||||
export class AgentChatService {
|
||||
@@ -91,12 +90,14 @@ export class AgentChatService {
|
||||
await this.messagePartRepository.save(dbParts);
|
||||
}
|
||||
|
||||
const messageContent = uiMessage.parts.find(
|
||||
(part) => part.type === 'text',
|
||||
)?.text;
|
||||
if (uiMessage.role === AgentChatMessageRole.USER) {
|
||||
const messageContent = uiMessage.parts.find(
|
||||
(part) => part.type === 'text',
|
||||
)?.text;
|
||||
|
||||
if (messageContent) {
|
||||
this.generateTitleIfNeeded(threadId, messageContent);
|
||||
if (messageContent) {
|
||||
this.generateTitleIfNeeded(threadId, messageContent);
|
||||
}
|
||||
}
|
||||
|
||||
return savedMessage;
|
||||
+113
-27
@@ -12,19 +12,20 @@ import { type Response } from 'express';
|
||||
import { type ExtendedUIMessage } from 'twenty-shared/ai';
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { type ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
|
||||
import { convertCentsToBillingCredits } from 'src/engine/core-modules/ai/utils/convert-cents-to-billing-credits.util';
|
||||
import { type ModelId } from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
|
||||
import { AIBillingService } from 'src/engine/metadata-modules/ai-billing/services/ai-billing.service';
|
||||
import { convertCentsToBillingCredits } from 'src/engine/metadata-modules/ai-billing/utils/convert-cents-to-billing-credits.util';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AgentChatMessageRole } from 'src/engine/metadata-modules/agent/agent-chat-message.entity';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/agent/agent-chat-thread.entity';
|
||||
import { AgentChatService } from 'src/engine/metadata-modules/agent/agent-chat.service';
|
||||
import { AgentExecutionService } from 'src/engine/metadata-modules/agent/agent-execution.service';
|
||||
import { AgentChatMessageRole } from 'src/engine/metadata-modules/ai-chat/entities/agent-chat-message.entity';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai-chat/entities/agent-chat-thread.entity';
|
||||
import { AgentChatService } from 'src/engine/metadata-modules/ai-chat/services/agent-chat.service';
|
||||
import { AgentExecutionService } from 'src/engine/metadata-modules/ai-agent/services/agent-execution.service';
|
||||
import {
|
||||
AgentException,
|
||||
AgentExceptionCode,
|
||||
} from 'src/engine/metadata-modules/agent/agent.exception';
|
||||
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/agent/types/recordIdsByObjectMetadataNameSingular.type';
|
||||
} from 'src/engine/metadata-modules/ai-agent/agent.exception';
|
||||
import { AgentPlanExecutorService } from 'src/engine/metadata-modules/ai-agent/services/agent-plan-executor.service';
|
||||
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai-agent/types/recordIdsByObjectMetadataNameSingular.type';
|
||||
import { AiRouterService } from 'src/engine/metadata-modules/ai-router/ai-router.service';
|
||||
|
||||
export type TokenUsage = {
|
||||
@@ -51,6 +52,7 @@ export class AgentStreamingService {
|
||||
private readonly threadRepository: Repository<AgentChatThreadEntity>,
|
||||
private readonly agentChatService: AgentChatService,
|
||||
private readonly agentExecutionService: AgentExecutionService,
|
||||
private readonly agentPlanExecutorService: AgentPlanExecutorService,
|
||||
private readonly aiRouterService: AiRouterService,
|
||||
private readonly aiBillingService: AIBillingService,
|
||||
) {}
|
||||
@@ -98,30 +100,15 @@ export class AgentStreamingService {
|
||||
{
|
||||
messages,
|
||||
workspaceId: workspace.id,
|
||||
routerModel: workspace.routerModel,
|
||||
fastModel: workspace.fastModel,
|
||||
smartModel: workspace.smartModel,
|
||||
},
|
||||
includeDebugInfo,
|
||||
);
|
||||
|
||||
const routingTime = Date.now() - routingStart;
|
||||
const { agent, debugInfo, toolHints } = routeResult;
|
||||
|
||||
if (!agent) {
|
||||
writer.write({
|
||||
type: 'data-routing-status' as const,
|
||||
id: 'routing-status',
|
||||
data: {
|
||||
text: '',
|
||||
state: 'error',
|
||||
},
|
||||
});
|
||||
throw new AgentException(
|
||||
'No agents available for routing',
|
||||
AgentExceptionCode.AGENT_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(`Using agent ${agent.id} for message routing`);
|
||||
const { debugInfo } = routeResult;
|
||||
|
||||
let routingCostInCredits: number | undefined;
|
||||
|
||||
@@ -149,6 +136,105 @@ export class AgentStreamingService {
|
||||
}
|
||||
}
|
||||
|
||||
if (routeResult.strategy === 'planned') {
|
||||
this.logger.log(
|
||||
`Executing planned strategy with ${routeResult.plan.steps.length} steps`,
|
||||
);
|
||||
this.logger.log(
|
||||
`Plan steps: ${routeResult.plan.steps.map((s) => `${s.stepNumber}. ${s.agentName}: ${s.task}`).join('; ')}`,
|
||||
);
|
||||
|
||||
writer.write({
|
||||
type: 'data-routing-status' as const,
|
||||
id: 'routing-status',
|
||||
data: {
|
||||
text: `Executing ${routeResult.plan.steps.length}-step plan`,
|
||||
state: 'routed',
|
||||
debug: {
|
||||
routingTimeMs: routingTime,
|
||||
planReasoning: routeResult.plan.reasoning,
|
||||
totalSteps: routeResult.plan.steps.length,
|
||||
steps: routeResult.plan.steps.map((s) => ({
|
||||
stepNumber: s.stepNumber,
|
||||
agent: s.agentName,
|
||||
task: s.task,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const planResult = await this.agentPlanExecutorService.executePlan({
|
||||
steps: routeResult.plan.steps,
|
||||
reasoning: routeResult.plan.reasoning,
|
||||
workspace,
|
||||
userWorkspaceId,
|
||||
recordIdsByObjectMetadataNameSingular,
|
||||
writer,
|
||||
onProgress: (progress) => {
|
||||
if (progress.type === 'step-started') {
|
||||
this.logger.log(
|
||||
`Starting step ${progress.stepNumber}: ${progress.agentName} - ${progress.task}`,
|
||||
);
|
||||
writer.write({
|
||||
type: 'data-routing-status' as const,
|
||||
id: `step-${progress.stepNumber}`,
|
||||
data: {
|
||||
text: `Step ${progress.stepNumber}/${routeResult.plan.steps.length}: ${progress.agentName} → ${progress.task}`,
|
||||
state: 'loading',
|
||||
},
|
||||
});
|
||||
} else if (progress.type === 'step-completed') {
|
||||
this.logger.log(
|
||||
`Completed step ${progress.stepNumber}: ${progress.agentName}`,
|
||||
);
|
||||
writer.write({
|
||||
type: 'data-routing-status' as const,
|
||||
id: `step-${progress.stepNumber}`,
|
||||
data: {
|
||||
text: `Step ${progress.stepNumber}/${routeResult.plan.steps.length}: ✓ ${progress.agentName} completed`,
|
||||
state: 'routed',
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
await this.agentChatService.addMessage({
|
||||
threadId,
|
||||
uiMessage: {
|
||||
role: AgentChatMessageRole.USER,
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text:
|
||||
messages[messages.length - 1].parts.find(
|
||||
(part) => part.type === 'text',
|
||||
)?.text ?? '',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await this.agentChatService.addMessage({
|
||||
threadId,
|
||||
uiMessage: {
|
||||
role: AgentChatMessageRole.ASSISTANT,
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: planResult.finalOutput,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { agent, toolHints } = routeResult;
|
||||
|
||||
this.logger.log(`Using agent ${agent.id} for message routing`);
|
||||
|
||||
const agentExecutionStart = Date.now();
|
||||
|
||||
const {
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { type ToolUIPart } from 'ai';
|
||||
import { type ExtendedUIMessagePart } from 'twenty-shared/ai';
|
||||
|
||||
import { type AgentChatMessagePartEntity } from 'src/engine/metadata-modules/agent/agent-chat-message-part.entity';
|
||||
import { type AgentChatMessagePartEntity } from 'src/engine/metadata-modules/ai-chat/entities/agent-chat-message-part.entity';
|
||||
|
||||
const isToolPart = (part: ExtendedUIMessagePart): part is ToolUIPart => {
|
||||
return part.type.includes('tool-') && 'toolCallId' in part;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
|
||||
import { AiService } from 'src/engine/metadata-modules/ai-models/services/ai.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AiModelRegistryService, AiService],
|
||||
exports: [AiModelRegistryService, AiService],
|
||||
})
|
||||
export class AiModelsModule {}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
|
||||
|
||||
import {
|
||||
AI_MODELS,
|
||||
DEFAULT_SMART_MODEL,
|
||||
ModelProvider,
|
||||
} from './ai-models.const';
|
||||
|
||||
describe('AI_MODELS', () => {
|
||||
it('should contain all expected models', () => {
|
||||
expect(AI_MODELS).toHaveLength(9);
|
||||
expect(AI_MODELS.map((model) => model.modelId)).toEqual([
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
'gpt-4-turbo',
|
||||
'claude-opus-4-20250514',
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-3-5-haiku-20241022',
|
||||
'grok-3',
|
||||
'grok-3-mini',
|
||||
'grok-4',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiModelRegistryService', () => {
|
||||
let SERVICE: AiModelRegistryService;
|
||||
let MOCK_CONFIG_SERVICE: jest.Mocked<TwentyConfigService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
MOCK_CONFIG_SERVICE = {
|
||||
get: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const MODULE: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AiModelRegistryService,
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
useValue: MOCK_CONFIG_SERVICE,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
SERVICE = MODULE.get<AiModelRegistryService>(AiModelRegistryService);
|
||||
});
|
||||
|
||||
it('should return effective model config for DEFAULT_SMART_MODEL', () => {
|
||||
MOCK_CONFIG_SERVICE.get.mockReturnValue('gpt-4o');
|
||||
|
||||
expect(() => SERVICE.getEffectiveModelConfig(DEFAULT_SMART_MODEL)).toThrow(
|
||||
'No AI models are available. Please configure at least one provider.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return effective model config for DEFAULT_SMART_MODEL when models are available', () => {
|
||||
MOCK_CONFIG_SERVICE.get.mockReturnValue('gpt-4o');
|
||||
|
||||
jest.spyOn(SERVICE, 'getAvailableModels').mockReturnValue([
|
||||
{
|
||||
modelId: 'gpt-4o',
|
||||
provider: ModelProvider.OPENAI,
|
||||
model: {} as any,
|
||||
},
|
||||
]);
|
||||
|
||||
jest.spyOn(SERVICE, 'getModel').mockReturnValue({
|
||||
modelId: 'gpt-4o',
|
||||
provider: ModelProvider.OPENAI,
|
||||
model: {} as any,
|
||||
});
|
||||
|
||||
const RESULT = SERVICE.getEffectiveModelConfig(DEFAULT_SMART_MODEL);
|
||||
|
||||
expect(RESULT).toBeDefined();
|
||||
expect(RESULT.modelId).toBe('gpt-4o');
|
||||
expect(RESULT.provider).toBe(ModelProvider.OPENAI);
|
||||
});
|
||||
|
||||
it('should return effective model config for DEFAULT_SMART_MODEL with custom model', () => {
|
||||
MOCK_CONFIG_SERVICE.get.mockReturnValue('mistral');
|
||||
|
||||
jest.spyOn(SERVICE, 'getAvailableModels').mockReturnValue([
|
||||
{
|
||||
modelId: 'mistral',
|
||||
provider: ModelProvider.OPENAI_COMPATIBLE,
|
||||
model: {} as any,
|
||||
},
|
||||
]);
|
||||
|
||||
jest.spyOn(SERVICE, 'getModel').mockReturnValue({
|
||||
modelId: 'mistral',
|
||||
provider: ModelProvider.OPENAI_COMPATIBLE,
|
||||
model: {} as any,
|
||||
});
|
||||
|
||||
const RESULT = SERVICE.getEffectiveModelConfig(DEFAULT_SMART_MODEL);
|
||||
|
||||
expect(RESULT).toBeDefined();
|
||||
expect(RESULT.modelId).toBe('mistral');
|
||||
expect(RESULT.provider).toBe(ModelProvider.OPENAI_COMPATIBLE);
|
||||
expect(RESULT.label).toBe('mistral');
|
||||
expect(RESULT.inputCostPer1kTokensInCents).toBe(0);
|
||||
expect(RESULT.outputCostPer1kTokensInCents).toBe(0);
|
||||
});
|
||||
|
||||
it('should return effective model config for specific model', () => {
|
||||
const RESULT = SERVICE.getEffectiveModelConfig('gpt-4o-mini');
|
||||
|
||||
expect(RESULT).toBeDefined();
|
||||
expect(RESULT.modelId).toBe('gpt-4o-mini');
|
||||
expect(RESULT.provider).toBe(ModelProvider.OPENAI);
|
||||
});
|
||||
|
||||
it('should return effective model config for custom model', () => {
|
||||
// Mock that the custom model exists in registry
|
||||
jest.spyOn(SERVICE, 'getModel').mockReturnValue({
|
||||
modelId: 'mistral',
|
||||
provider: ModelProvider.OPENAI_COMPATIBLE,
|
||||
model: {} as any,
|
||||
});
|
||||
|
||||
const RESULT = SERVICE.getEffectiveModelConfig('mistral');
|
||||
|
||||
expect(RESULT).toBeDefined();
|
||||
expect(RESULT.modelId).toBe('mistral');
|
||||
expect(RESULT.provider).toBe(ModelProvider.OPENAI_COMPATIBLE);
|
||||
expect(RESULT.label).toBe('mistral');
|
||||
expect(RESULT.inputCostPer1kTokensInCents).toBe(0);
|
||||
expect(RESULT.outputCostPer1kTokensInCents).toBe(0);
|
||||
});
|
||||
|
||||
it('should throw error for non-existent model', () => {
|
||||
jest.spyOn(SERVICE, 'getModel').mockReturnValue(undefined);
|
||||
|
||||
expect(() => SERVICE.getEffectiveModelConfig('non-existent-model')).toThrow(
|
||||
'Model with ID non-existent-model not found',
|
||||
);
|
||||
});
|
||||
});
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
export enum ModelProvider {
|
||||
NONE = 'none',
|
||||
OPENAI = 'openai',
|
||||
ANTHROPIC = 'anthropic',
|
||||
OPENAI_COMPATIBLE = 'open_ai_compatible',
|
||||
XAI = 'xai',
|
||||
}
|
||||
|
||||
export const DEFAULT_FAST_MODEL = 'default-fast-model' as const;
|
||||
export const DEFAULT_SMART_MODEL = 'default-smart-model' as const;
|
||||
|
||||
export type ModelId =
|
||||
| typeof DEFAULT_FAST_MODEL
|
||||
| typeof DEFAULT_SMART_MODEL
|
||||
| 'gpt-4o'
|
||||
| 'gpt-4o-mini'
|
||||
| 'gpt-4-turbo'
|
||||
| 'claude-opus-4-20250514'
|
||||
| 'claude-sonnet-4-20250514'
|
||||
| 'claude-3-5-haiku-20241022'
|
||||
| 'grok-3'
|
||||
| 'grok-3-mini'
|
||||
| 'grok-4'
|
||||
| string; // Allow custom model names
|
||||
|
||||
export type SupportedFileType =
|
||||
| 'image/png'
|
||||
| 'image/jpeg'
|
||||
| 'image/gif'
|
||||
| 'image/webp'
|
||||
| 'application/pdf'
|
||||
| 'text/plain'
|
||||
| 'text/html'
|
||||
| 'text/csv'
|
||||
| 'application/json';
|
||||
|
||||
export interface AIModelConfig {
|
||||
modelId: ModelId;
|
||||
label: string;
|
||||
description: string;
|
||||
provider: ModelProvider;
|
||||
inputCostPer1kTokensInCents: number;
|
||||
outputCostPer1kTokensInCents: number;
|
||||
contextWindowTokens: number;
|
||||
maxOutputTokens: number;
|
||||
supportedFileTypes?: SupportedFileType[];
|
||||
doesSupportThinking?: boolean;
|
||||
nativeCapabilities?: {
|
||||
webSearch?: boolean;
|
||||
twitterSearch?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const AI_MODELS: AIModelConfig[] = [
|
||||
{
|
||||
modelId: 'gpt-4o',
|
||||
label: 'GPT-4o',
|
||||
description:
|
||||
'Most advanced multimodal model with strong reasoning, vision, and coding capabilities',
|
||||
provider: ModelProvider.OPENAI,
|
||||
inputCostPer1kTokensInCents: 0.25,
|
||||
outputCostPer1kTokensInCents: 1.0,
|
||||
contextWindowTokens: 128000,
|
||||
maxOutputTokens: 16384,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: 'gpt-4o-mini',
|
||||
label: 'GPT-4o Mini',
|
||||
description:
|
||||
'Fast and cost-efficient model for lightweight tasks and high-volume operations',
|
||||
provider: ModelProvider.OPENAI,
|
||||
inputCostPer1kTokensInCents: 0.015,
|
||||
outputCostPer1kTokensInCents: 0.06,
|
||||
contextWindowTokens: 128000,
|
||||
maxOutputTokens: 16384,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: 'gpt-4-turbo',
|
||||
label: 'GPT-4 Turbo',
|
||||
description:
|
||||
'Previous generation high-performance model with vision capabilities',
|
||||
provider: ModelProvider.OPENAI,
|
||||
inputCostPer1kTokensInCents: 1.0,
|
||||
outputCostPer1kTokensInCents: 3.0,
|
||||
contextWindowTokens: 128000,
|
||||
maxOutputTokens: 4096,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
nativeCapabilities: {
|
||||
webSearch: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: 'claude-opus-4-20250514',
|
||||
label: 'Claude Opus 4',
|
||||
description:
|
||||
'Most powerful Claude model with extended thinking for complex reasoning tasks',
|
||||
provider: ModelProvider.ANTHROPIC,
|
||||
inputCostPer1kTokensInCents: 1.5,
|
||||
outputCostPer1kTokensInCents: 7.5,
|
||||
contextWindowTokens: 200000,
|
||||
maxOutputTokens: 8192,
|
||||
supportedFileTypes: [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/html',
|
||||
'text/csv',
|
||||
],
|
||||
doesSupportThinking: true,
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: 'claude-sonnet-4-20250514',
|
||||
label: 'Claude Sonnet 4',
|
||||
description:
|
||||
'Balanced model with strong performance and extended thinking capabilities',
|
||||
provider: ModelProvider.ANTHROPIC,
|
||||
inputCostPer1kTokensInCents: 0.3,
|
||||
outputCostPer1kTokensInCents: 1.5,
|
||||
contextWindowTokens: 200000,
|
||||
maxOutputTokens: 8192,
|
||||
supportedFileTypes: [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/html',
|
||||
'text/csv',
|
||||
],
|
||||
doesSupportThinking: true,
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: 'claude-3-5-haiku-20241022',
|
||||
label: 'Claude Haiku 3.5',
|
||||
description:
|
||||
'Fast and efficient model optimized for speed and cost-effectiveness',
|
||||
provider: ModelProvider.ANTHROPIC,
|
||||
inputCostPer1kTokensInCents: 0.08,
|
||||
outputCostPer1kTokensInCents: 0.4,
|
||||
contextWindowTokens: 200000,
|
||||
maxOutputTokens: 8192,
|
||||
supportedFileTypes: [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/html',
|
||||
'text/csv',
|
||||
],
|
||||
doesSupportThinking: false,
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: 'grok-3',
|
||||
label: 'Grok-3',
|
||||
description:
|
||||
'Advanced model with web and Twitter search, optimized for real-time information',
|
||||
provider: ModelProvider.XAI,
|
||||
inputCostPer1kTokensInCents: 0.3,
|
||||
outputCostPer1kTokensInCents: 1.5,
|
||||
contextWindowTokens: 131072,
|
||||
maxOutputTokens: 8192,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
twitterSearch: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: 'grok-3-mini',
|
||||
label: 'Grok-3 Mini',
|
||||
description:
|
||||
'Lightweight model with web and Twitter search for fast, cost-effective operations',
|
||||
provider: ModelProvider.XAI,
|
||||
inputCostPer1kTokensInCents: 0.03,
|
||||
outputCostPer1kTokensInCents: 0.05,
|
||||
contextWindowTokens: 131072,
|
||||
maxOutputTokens: 8192,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
twitterSearch: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: 'grok-4',
|
||||
label: 'Grok-4',
|
||||
description:
|
||||
'Most capable Grok model with enhanced reasoning, web and Twitter search',
|
||||
provider: ModelProvider.XAI,
|
||||
inputCostPer1kTokensInCents: 0.5,
|
||||
outputCostPer1kTokensInCents: 2.5,
|
||||
contextWindowTokens: 131072,
|
||||
maxOutputTokens: 8192,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
twitterSearch: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export const AI_TELEMETRY_CONFIG = {
|
||||
isEnabled: true,
|
||||
recordInputs: true,
|
||||
recordOutputs: true,
|
||||
};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
// Configuration: $0.00001 = 1 credit
|
||||
export const DOLLAR_TO_CREDIT_MULTIPLIER = 1000000; // 1 / 0.000001 = 1000000 credits per dollar
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { anthropic } from '@ai-sdk/anthropic';
|
||||
import { createOpenAI, openai } from '@ai-sdk/openai';
|
||||
import { xai } from '@ai-sdk/xai';
|
||||
import { type LanguageModel } from 'ai';
|
||||
|
||||
import {
|
||||
AI_MODELS,
|
||||
DEFAULT_FAST_MODEL,
|
||||
DEFAULT_SMART_MODEL,
|
||||
ModelProvider,
|
||||
type AIModelConfig,
|
||||
} from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
export interface RegisteredAIModel {
|
||||
modelId: string;
|
||||
provider: ModelProvider;
|
||||
model: LanguageModel;
|
||||
doesSupportThinking?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AiModelRegistryService {
|
||||
private modelRegistry: Map<string, RegisteredAIModel> = new Map();
|
||||
|
||||
constructor(private twentyConfigService: TwentyConfigService) {
|
||||
this.buildModelRegistry();
|
||||
}
|
||||
|
||||
private buildModelRegistry(): void {
|
||||
this.modelRegistry.clear();
|
||||
|
||||
const openaiApiKey = this.twentyConfigService.get('OPENAI_API_KEY');
|
||||
|
||||
if (openaiApiKey) {
|
||||
this.registerOpenAIModels();
|
||||
}
|
||||
|
||||
const anthropicApiKey = this.twentyConfigService.get('ANTHROPIC_API_KEY');
|
||||
|
||||
if (anthropicApiKey) {
|
||||
this.registerAnthropicModels();
|
||||
}
|
||||
|
||||
const xaiApiKey = this.twentyConfigService.get('XAI_API_KEY');
|
||||
|
||||
if (xaiApiKey) {
|
||||
this.registerXaiModels();
|
||||
}
|
||||
|
||||
const openaiCompatibleBaseUrl = this.twentyConfigService.get(
|
||||
'OPENAI_COMPATIBLE_BASE_URL',
|
||||
);
|
||||
const openaiCompatibleModelNames = this.twentyConfigService.get(
|
||||
'OPENAI_COMPATIBLE_MODEL_NAMES',
|
||||
);
|
||||
|
||||
if (openaiCompatibleBaseUrl && openaiCompatibleModelNames) {
|
||||
this.registerOpenAICompatibleModels(
|
||||
openaiCompatibleBaseUrl,
|
||||
openaiCompatibleModelNames,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private registerOpenAIModels(): void {
|
||||
const openaiModels = AI_MODELS.filter(
|
||||
(model) => model.provider === ModelProvider.OPENAI,
|
||||
);
|
||||
|
||||
openaiModels.forEach((modelConfig) => {
|
||||
this.modelRegistry.set(modelConfig.modelId, {
|
||||
modelId: modelConfig.modelId,
|
||||
provider: ModelProvider.OPENAI,
|
||||
model: openai(modelConfig.modelId),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private registerAnthropicModels(): void {
|
||||
const anthropicModels = AI_MODELS.filter(
|
||||
(model) => model.provider === ModelProvider.ANTHROPIC,
|
||||
);
|
||||
|
||||
anthropicModels.forEach((modelConfig) => {
|
||||
this.modelRegistry.set(modelConfig.modelId, {
|
||||
modelId: modelConfig.modelId,
|
||||
provider: ModelProvider.ANTHROPIC,
|
||||
model: anthropic(modelConfig.modelId),
|
||||
doesSupportThinking: modelConfig.doesSupportThinking,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private registerXaiModels(): void {
|
||||
const xaiModels = AI_MODELS.filter(
|
||||
(model) => model.provider === ModelProvider.XAI,
|
||||
);
|
||||
|
||||
xaiModels.forEach((modelConfig) => {
|
||||
this.modelRegistry.set(modelConfig.modelId, {
|
||||
modelId: modelConfig.modelId,
|
||||
provider: ModelProvider.XAI,
|
||||
model: xai(modelConfig.modelId),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private registerOpenAICompatibleModels(
|
||||
baseUrl: string,
|
||||
modelNamesString: string,
|
||||
): void {
|
||||
const apiKey = this.twentyConfigService.get('OPENAI_COMPATIBLE_API_KEY');
|
||||
const provider = createOpenAI({
|
||||
baseURL: baseUrl,
|
||||
apiKey: apiKey,
|
||||
});
|
||||
|
||||
const modelNames = modelNamesString
|
||||
.split(',')
|
||||
.map((name) => name.trim())
|
||||
.filter((name) => name.length > 0);
|
||||
|
||||
modelNames.forEach((modelId) => {
|
||||
this.modelRegistry.set(modelId, {
|
||||
modelId,
|
||||
provider: ModelProvider.OPENAI_COMPATIBLE,
|
||||
model: provider(modelId),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getModel(modelId: string): RegisteredAIModel | undefined {
|
||||
return this.modelRegistry.get(modelId);
|
||||
}
|
||||
|
||||
getAvailableModels(): RegisteredAIModel[] {
|
||||
return Array.from(this.modelRegistry.values());
|
||||
}
|
||||
|
||||
getDefaultSpeedModel(): RegisteredAIModel {
|
||||
const defaultModelId = this.twentyConfigService.get(
|
||||
'DEFAULT_AI_SPEED_MODEL_ID',
|
||||
);
|
||||
let model = this.getModel(defaultModelId);
|
||||
|
||||
if (!model) {
|
||||
const availableModels = this.getAvailableModels();
|
||||
|
||||
model = availableModels[0];
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
getDefaultPerformanceModel(): RegisteredAIModel {
|
||||
const defaultModelId = this.twentyConfigService.get(
|
||||
'DEFAULT_AI_PERFORMANCE_MODEL_ID',
|
||||
);
|
||||
let model = this.getModel(defaultModelId);
|
||||
|
||||
if (!model) {
|
||||
const availableModels = this.getAvailableModels();
|
||||
|
||||
model = availableModels[0];
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
getEffectiveModelConfig(modelId: string): AIModelConfig {
|
||||
if (modelId === DEFAULT_FAST_MODEL || modelId === DEFAULT_SMART_MODEL) {
|
||||
const defaultModel =
|
||||
modelId === DEFAULT_FAST_MODEL
|
||||
? this.getDefaultSpeedModel()
|
||||
: this.getDefaultPerformanceModel();
|
||||
|
||||
if (!defaultModel) {
|
||||
throw new Error(
|
||||
'No AI models are available. Please configure at least one provider.',
|
||||
);
|
||||
}
|
||||
|
||||
const modelConfig = AI_MODELS.find(
|
||||
(model) => model.modelId === defaultModel.modelId,
|
||||
);
|
||||
|
||||
if (modelConfig) {
|
||||
return modelConfig;
|
||||
}
|
||||
|
||||
return this.createDefaultConfigForCustomModel(defaultModel);
|
||||
}
|
||||
|
||||
const predefinedModel = AI_MODELS.find(
|
||||
(model) => model.modelId === modelId,
|
||||
);
|
||||
|
||||
if (predefinedModel) {
|
||||
return predefinedModel;
|
||||
}
|
||||
|
||||
const registeredModel = this.getModel(modelId);
|
||||
|
||||
if (registeredModel) {
|
||||
return this.createDefaultConfigForCustomModel(registeredModel);
|
||||
}
|
||||
|
||||
throw new Error(`Model with ID ${modelId} not found`);
|
||||
}
|
||||
|
||||
private createDefaultConfigForCustomModel(
|
||||
registeredModel: RegisteredAIModel,
|
||||
): AIModelConfig {
|
||||
return {
|
||||
modelId: registeredModel.modelId,
|
||||
label: registeredModel.modelId,
|
||||
description: `Custom model: ${registeredModel.modelId}`,
|
||||
provider: registeredModel.provider,
|
||||
inputCostPer1kTokensInCents: 0,
|
||||
outputCostPer1kTokensInCents: 0,
|
||||
contextWindowTokens: 128000,
|
||||
maxOutputTokens: 4096,
|
||||
};
|
||||
}
|
||||
|
||||
// Force refresh the registry (useful if config changes)
|
||||
refreshRegistry(): void {
|
||||
this.buildModelRegistry();
|
||||
}
|
||||
|
||||
async resolveModelForAgent(agent: { modelId: string } | null) {
|
||||
const aiModel = this.getEffectiveModelConfig(
|
||||
agent?.modelId ?? DEFAULT_SMART_MODEL,
|
||||
);
|
||||
|
||||
await this.validateApiKey(aiModel.provider);
|
||||
const registeredModel = this.getModel(aiModel.modelId);
|
||||
|
||||
if (!registeredModel) {
|
||||
throw new Error(`Model ${aiModel.modelId} not found in registry`);
|
||||
}
|
||||
|
||||
return registeredModel;
|
||||
}
|
||||
|
||||
async validateApiKey(provider: ModelProvider): Promise<void> {
|
||||
let apiKey: string | undefined;
|
||||
|
||||
switch (provider) {
|
||||
case ModelProvider.OPENAI:
|
||||
apiKey = this.twentyConfigService.get('OPENAI_API_KEY');
|
||||
break;
|
||||
case ModelProvider.ANTHROPIC:
|
||||
apiKey = this.twentyConfigService.get('ANTHROPIC_API_KEY');
|
||||
break;
|
||||
case ModelProvider.XAI:
|
||||
apiKey = this.twentyConfigService.get('XAI_API_KEY');
|
||||
break;
|
||||
case ModelProvider.OPENAI_COMPATIBLE:
|
||||
apiKey = this.twentyConfigService.get('OPENAI_COMPATIBLE_API_KEY');
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(`${provider.toUpperCase()} API key not configured`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { LanguageModel, type ModelMessage, streamText } from 'ai';
|
||||
|
||||
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai-models/constants/ai-telemetry.const';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
constructor(private aiModelRegistryService: AiModelRegistryService) {}
|
||||
|
||||
getModel(modelId: string | undefined) {
|
||||
const registeredModel = modelId
|
||||
? this.aiModelRegistryService.getModel(modelId)
|
||||
: this.aiModelRegistryService.getDefaultPerformanceModel();
|
||||
|
||||
if (!registeredModel) {
|
||||
throw new Error(
|
||||
modelId
|
||||
? `Model "${modelId}" is not available. Please check your configuration.`
|
||||
: 'No AI models are available. Please configure at least one provider.',
|
||||
);
|
||||
}
|
||||
|
||||
return registeredModel.model;
|
||||
}
|
||||
|
||||
streamText({
|
||||
messages,
|
||||
options,
|
||||
}: {
|
||||
messages: ModelMessage[];
|
||||
options: {
|
||||
temperature?: number;
|
||||
maxOutputTokens?: number;
|
||||
model: LanguageModel;
|
||||
};
|
||||
}) {
|
||||
return streamText({
|
||||
model: options.model,
|
||||
messages,
|
||||
temperature: options?.temperature,
|
||||
maxOutputTokens: options?.maxOutputTokens,
|
||||
experimental_telemetry: AI_TELEMETRY_CONFIG,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
|
||||
import { AiModelsModule } from 'src/engine/metadata-modules/ai-models/ai-models.module';
|
||||
import { AiToolsModule } from 'src/engine/metadata-modules/ai-tools/ai-tools.module';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
|
||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||
|
||||
import { AiRouterService } from './ai-router.service';
|
||||
|
||||
import { AiRouterPlanGeneratorService } from './services/ai-router-plan-generator.service';
|
||||
import { AiRouterStrategyDeciderService } from './services/ai-router-strategy-decider.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AgentEntity, WorkspaceEntity]),
|
||||
AiModule,
|
||||
AiModelsModule,
|
||||
AiToolsModule,
|
||||
ObjectMetadataModule,
|
||||
],
|
||||
providers: [AiRouterService],
|
||||
providers: [
|
||||
AiRouterService,
|
||||
AiRouterStrategyDeciderService,
|
||||
AiRouterPlanGeneratorService,
|
||||
],
|
||||
exports: [AiRouterService],
|
||||
})
|
||||
export class AiRouterModule {}
|
||||
|
||||
+284
-227
@@ -1,42 +1,31 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import {
|
||||
generateObject,
|
||||
type UIDataTypes,
|
||||
type UIMessage,
|
||||
type UITools,
|
||||
} from 'ai';
|
||||
import { type Repository } from 'typeorm';
|
||||
import { z } from 'zod';
|
||||
import { type UIDataTypes, type UIMessage, type UITools } from 'ai';
|
||||
import { IsNull, type Repository } from 'typeorm';
|
||||
|
||||
import { type ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
import { AI_TELEMETRY_CONFIG } from 'src/engine/core-modules/ai/constants/ai-telemetry.const';
|
||||
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/agent/utils/is-workflow-related-object.util';
|
||||
import { type ModelId } from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
|
||||
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai-agent/utils/is-workflow-related-object.util';
|
||||
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
|
||||
import { DATA_MANIPULATOR_AGENT } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/data-manipulator-agent';
|
||||
import { HELPER_AGENT } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/helper-agent';
|
||||
|
||||
import { AiRouterPlanGeneratorService } from './services/ai-router-plan-generator.service';
|
||||
import {
|
||||
AiRouterStrategyDeciderService,
|
||||
type StrategyDecision,
|
||||
} from './services/ai-router-strategy-decider.service';
|
||||
import {
|
||||
type RouterDebugInfo,
|
||||
type UnifiedRouterResult,
|
||||
} from './types/router-result.interface';
|
||||
import { type ToolHints } from './types/tool-hints.interface';
|
||||
|
||||
export interface AiRouterContext {
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[];
|
||||
workspaceId: string;
|
||||
routerModel: ModelId;
|
||||
}
|
||||
|
||||
export interface AiRouterResult {
|
||||
agent: AgentEntity | null;
|
||||
toolHints?: ToolHints;
|
||||
debugInfo?: {
|
||||
availableAgents: Array<{ id: string; label: string }>;
|
||||
routerModel: string;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
};
|
||||
fastModel: ModelId;
|
||||
smartModel: ModelId;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -46,160 +35,292 @@ export class AiRouterService {
|
||||
constructor(
|
||||
@InjectRepository(AgentEntity)
|
||||
private readonly agentRepository: Repository<AgentEntity>,
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
private readonly strategyDecider: AiRouterStrategyDeciderService,
|
||||
private readonly planGenerator: AiRouterPlanGeneratorService,
|
||||
private readonly objectMetadataService: ObjectMetadataServiceV2,
|
||||
) {}
|
||||
|
||||
// Routes a user message to the most appropriate agent
|
||||
// Uses AI to analyze the conversation and select the best agent
|
||||
// Returns the selected agent along with tool hints for optimization
|
||||
async routeMessage(
|
||||
context: AiRouterContext,
|
||||
includeDebugInfo = false,
|
||||
): Promise<AiRouterResult> {
|
||||
): Promise<UnifiedRouterResult> {
|
||||
try {
|
||||
const { messages, workspaceId, routerModel } = context;
|
||||
|
||||
const { messages, workspaceId, fastModel, smartModel } = context;
|
||||
const availableAgents = await this.getAvailableAgents(workspaceId);
|
||||
|
||||
if (availableAgents.length === 0) {
|
||||
this.logger.warn('No agents available for routing');
|
||||
this.logger.log(
|
||||
`[ROUTER] Available agents (${availableAgents.length}): ${availableAgents.map((a) => `${a.label} (${a.name})`).join(', ')}`,
|
||||
);
|
||||
|
||||
return { agent: null };
|
||||
if (availableAgents.length === 0) {
|
||||
return await this.handleNoAgentsAvailable(workspaceId);
|
||||
}
|
||||
|
||||
const debugInfo: AiRouterResult['debugInfo'] = includeDebugInfo
|
||||
? {
|
||||
availableAgents: availableAgents.map((agent) => ({
|
||||
id: agent.id,
|
||||
label: agent.label,
|
||||
})),
|
||||
routerModel: String(routerModel),
|
||||
}
|
||||
: undefined;
|
||||
const debugInfo = this.createDebugInfo(
|
||||
includeDebugInfo,
|
||||
availableAgents,
|
||||
smartModel,
|
||||
fastModel,
|
||||
);
|
||||
|
||||
if (availableAgents.length === 1) {
|
||||
return { agent: availableAgents[0], debugInfo };
|
||||
return this.createSimpleResult(availableAgents[0], debugInfo);
|
||||
}
|
||||
|
||||
const conversationHistory = messages
|
||||
.slice(0, -1)
|
||||
.map((msg) => {
|
||||
const textContent =
|
||||
msg.parts.find((part) => part.type === 'text')?.text || '';
|
||||
|
||||
return `${msg.role}: ${textContent}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const currentMessage =
|
||||
messages[messages.length - 1]?.parts.find(
|
||||
(part) => part.type === 'text',
|
||||
)?.text || '';
|
||||
|
||||
const model = this.getRouterModel(routerModel);
|
||||
const workspaceObjectsList =
|
||||
await this.buildWorkspaceObjectsList(workspaceId);
|
||||
const agentDescriptions = this.buildAgentDescriptions(
|
||||
return await this.routeToMultipleAgents({
|
||||
messages,
|
||||
workspaceId,
|
||||
availableAgents,
|
||||
workspaceObjectsList,
|
||||
);
|
||||
|
||||
const systemPrompt = this.buildRouterSystemPrompt(agentDescriptions);
|
||||
const userPrompt = this.buildRouterUserPrompt(
|
||||
conversationHistory,
|
||||
currentMessage,
|
||||
);
|
||||
|
||||
const agentIds = availableAgents.map((agent) => agent.id);
|
||||
|
||||
if (agentIds.length === 0) {
|
||||
throw new Error('No agent IDs available for routing schema');
|
||||
}
|
||||
|
||||
const routerDecisionSchema = z.object({
|
||||
agentId: z
|
||||
.enum([agentIds[0], ...agentIds.slice(1)])
|
||||
.describe('The ID of the most suitable agent to handle this message'),
|
||||
toolHints: z
|
||||
.object({
|
||||
relevantObjects: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
'Names of the specific objects mentioned in the query (e.g., "person", "company")',
|
||||
),
|
||||
operations: z
|
||||
.array(z.enum(['find', 'create', 'update', 'delete']))
|
||||
.optional()
|
||||
.describe(
|
||||
'Specific operations needed: find (search/query), create (new records), update (modify), delete (remove)',
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const ROUTER_TEMPERATURE = 0.1; // Low temperature for deterministic routing
|
||||
|
||||
const result = await generateObject({
|
||||
model,
|
||||
system: systemPrompt,
|
||||
prompt: userPrompt,
|
||||
schema: routerDecisionSchema,
|
||||
temperature: ROUTER_TEMPERATURE,
|
||||
experimental_telemetry: AI_TELEMETRY_CONFIG,
|
||||
});
|
||||
|
||||
const selectedAgent = availableAgents.find(
|
||||
(agent) => agent.id === result.object.agentId,
|
||||
);
|
||||
|
||||
if (includeDebugInfo && debugInfo) {
|
||||
try {
|
||||
const usage = await result.usage;
|
||||
|
||||
const usageWithTokens = usage as {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
};
|
||||
|
||||
debugInfo.promptTokens =
|
||||
usageWithTokens.inputTokens ?? usageWithTokens.promptTokens ?? 0;
|
||||
debugInfo.completionTokens =
|
||||
usageWithTokens.outputTokens ??
|
||||
usageWithTokens.completionTokens ??
|
||||
0;
|
||||
debugInfo.totalTokens = usageWithTokens.totalTokens ?? 0;
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to get routing token usage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agent: selectedAgent ?? null,
|
||||
toolHints: result.object.toolHints,
|
||||
fastModel,
|
||||
smartModel,
|
||||
debugInfo,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'Routing to agent failed, falling back to Helper agent:',
|
||||
error,
|
||||
);
|
||||
|
||||
const helperAgent = await this.getHelperAgent(context.workspaceId);
|
||||
|
||||
return { agent: helperAgent };
|
||||
return await this.handleRoutingError(error, context.workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleNoAgentsAvailable(
|
||||
workspaceId: string,
|
||||
): Promise<UnifiedRouterResult> {
|
||||
this.logger.warn('No agents available for routing');
|
||||
|
||||
const helperAgent = await this.getHelperAgent(workspaceId);
|
||||
|
||||
if (!helperAgent) {
|
||||
throw new Error('No helper agent available');
|
||||
}
|
||||
|
||||
return {
|
||||
strategy: 'simple',
|
||||
agent: helperAgent,
|
||||
};
|
||||
}
|
||||
|
||||
private createDebugInfo(
|
||||
includeDebugInfo: boolean,
|
||||
availableAgents: AgentEntity[],
|
||||
smartModel: ModelId,
|
||||
fastModel: ModelId,
|
||||
): RouterDebugInfo | undefined {
|
||||
if (!includeDebugInfo) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
availableAgents: availableAgents.map((agent) => ({
|
||||
id: agent.id,
|
||||
label: agent.label,
|
||||
})),
|
||||
routerModel: String(smartModel ?? fastModel),
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private createSimpleResult(
|
||||
agent: AgentEntity,
|
||||
debugInfo?: RouterDebugInfo,
|
||||
toolHints?: ToolHints,
|
||||
): UnifiedRouterResult {
|
||||
return {
|
||||
strategy: 'simple',
|
||||
agent,
|
||||
toolHints,
|
||||
debugInfo,
|
||||
};
|
||||
}
|
||||
|
||||
private async routeToMultipleAgents(params: {
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[];
|
||||
workspaceId: string;
|
||||
availableAgents: AgentEntity[];
|
||||
fastModel: ModelId;
|
||||
smartModel: ModelId;
|
||||
debugInfo?: RouterDebugInfo;
|
||||
}): Promise<UnifiedRouterResult> {
|
||||
const {
|
||||
messages,
|
||||
workspaceId,
|
||||
availableAgents,
|
||||
fastModel,
|
||||
smartModel,
|
||||
debugInfo,
|
||||
} = params;
|
||||
|
||||
const workspaceObjectsList =
|
||||
await this.buildWorkspaceObjectsList(workspaceId);
|
||||
const agentDescriptions = this.buildAgentDescriptions(
|
||||
availableAgents,
|
||||
workspaceObjectsList,
|
||||
);
|
||||
|
||||
this.logRoutingContext(messages, agentDescriptions);
|
||||
|
||||
const strategyDecision = await this.strategyDecider.decideStrategy({
|
||||
messages,
|
||||
availableAgents,
|
||||
agentDescriptions,
|
||||
fastModel,
|
||||
});
|
||||
|
||||
if (strategyDecision.strategy === 'simple') {
|
||||
return this.handleSimpleStrategy(
|
||||
strategyDecision,
|
||||
availableAgents,
|
||||
debugInfo,
|
||||
);
|
||||
}
|
||||
|
||||
return await this.handlePlannedStrategy({
|
||||
messages,
|
||||
availableAgents,
|
||||
agentDescriptions,
|
||||
smartModel,
|
||||
debugInfo,
|
||||
});
|
||||
}
|
||||
|
||||
private logRoutingContext(
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[],
|
||||
agentDescriptions: string,
|
||||
) {
|
||||
this.logger.log(`[ROUTER] Agent descriptions:\n${agentDescriptions}`);
|
||||
|
||||
const currentMessage =
|
||||
messages[messages.length - 1]?.parts.find((part) => part.type === 'text')
|
||||
?.text || '';
|
||||
|
||||
this.logger.log(
|
||||
`[ROUTER] User message: "${currentMessage.substring(0, 100)}..."`,
|
||||
);
|
||||
}
|
||||
|
||||
private handleSimpleStrategy(
|
||||
strategyDecision: StrategyDecision,
|
||||
availableAgents: AgentEntity[],
|
||||
debugInfo?: RouterDebugInfo,
|
||||
): UnifiedRouterResult {
|
||||
if (!strategyDecision.agentName) {
|
||||
throw new Error('agentName is required for simple strategy');
|
||||
}
|
||||
|
||||
const selectedAgent = this.findAgentByName(
|
||||
strategyDecision.agentName,
|
||||
availableAgents,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`[ROUTER] Routing to ${selectedAgent.label} (${selectedAgent.name})`,
|
||||
);
|
||||
|
||||
return this.createSimpleResult(
|
||||
selectedAgent,
|
||||
debugInfo,
|
||||
strategyDecision.toolHints,
|
||||
);
|
||||
}
|
||||
|
||||
private async handlePlannedStrategy(params: {
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[];
|
||||
availableAgents: AgentEntity[];
|
||||
agentDescriptions: string;
|
||||
smartModel: ModelId;
|
||||
debugInfo?: RouterDebugInfo;
|
||||
}): Promise<UnifiedRouterResult> {
|
||||
const {
|
||||
messages,
|
||||
availableAgents,
|
||||
agentDescriptions,
|
||||
smartModel,
|
||||
debugInfo,
|
||||
} = params;
|
||||
|
||||
const plan = await this.planGenerator.generatePlan({
|
||||
messages,
|
||||
availableAgents,
|
||||
agentDescriptions,
|
||||
smartModel,
|
||||
});
|
||||
|
||||
if (plan.steps.length === 1) {
|
||||
return this.convertSingleStepPlanToSimple(
|
||||
plan.steps[0],
|
||||
availableAgents,
|
||||
debugInfo,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[ROUTER] Executing planned strategy with ${plan.steps.length} steps`,
|
||||
);
|
||||
|
||||
return {
|
||||
strategy: 'planned',
|
||||
plan,
|
||||
debugInfo,
|
||||
};
|
||||
}
|
||||
|
||||
private convertSingleStepPlanToSimple(
|
||||
step: { agentName: string },
|
||||
availableAgents: AgentEntity[],
|
||||
debugInfo?: RouterDebugInfo,
|
||||
): UnifiedRouterResult {
|
||||
this.logger.log(
|
||||
`[ROUTER] Plan has only 1 step, converting to simple strategy`,
|
||||
);
|
||||
|
||||
const selectedAgent = this.findAgentByName(step.agentName, availableAgents);
|
||||
|
||||
return this.createSimpleResult(selectedAgent, debugInfo);
|
||||
}
|
||||
|
||||
private findAgentByName(
|
||||
agentName: string,
|
||||
availableAgents: AgentEntity[],
|
||||
): AgentEntity {
|
||||
const selectedAgent = availableAgents.find(
|
||||
(agent) => agent.name === agentName,
|
||||
);
|
||||
|
||||
if (!selectedAgent) {
|
||||
this.logger.error(
|
||||
`[ROUTER] Agent "${agentName}" not found in available agents: ${availableAgents.map((a) => a.name).join(', ')}`,
|
||||
);
|
||||
throw new Error(`Selected agent ${agentName} not found`);
|
||||
}
|
||||
|
||||
return selectedAgent;
|
||||
}
|
||||
|
||||
private async handleRoutingError(
|
||||
error: unknown,
|
||||
workspaceId: string,
|
||||
): Promise<UnifiedRouterResult> {
|
||||
this.logger.error(
|
||||
'Routing with planning failed, falling back to Helper agent:',
|
||||
error,
|
||||
);
|
||||
|
||||
const helperAgent = await this.getHelperAgent(workspaceId);
|
||||
|
||||
if (!helperAgent) {
|
||||
throw new Error('No helper agent available for fallback');
|
||||
}
|
||||
|
||||
return {
|
||||
strategy: 'simple',
|
||||
agent: helperAgent,
|
||||
};
|
||||
}
|
||||
|
||||
private async getAvailableAgents(
|
||||
workspaceId: string,
|
||||
): Promise<AgentEntity[]> {
|
||||
const agents = await this.agentRepository.find({
|
||||
where: { workspaceId, deletedAt: undefined },
|
||||
where: { workspaceId, deletedAt: IsNull() },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
@@ -219,27 +340,6 @@ export class AiRouterService {
|
||||
return helperAgent;
|
||||
}
|
||||
|
||||
private getRouterModel(modelId: ModelId) {
|
||||
if (modelId === 'auto') {
|
||||
const registeredModel =
|
||||
this.aiModelRegistryService.getDefaultSpeedModel();
|
||||
|
||||
if (!registeredModel) {
|
||||
throw new Error('No router model available');
|
||||
}
|
||||
|
||||
return registeredModel.model;
|
||||
}
|
||||
|
||||
const registeredModel = this.aiModelRegistryService.getModel(modelId);
|
||||
|
||||
if (!registeredModel) {
|
||||
throw new Error(`Router model "${modelId}" not available`);
|
||||
}
|
||||
|
||||
return registeredModel.model;
|
||||
}
|
||||
|
||||
private async buildWorkspaceObjectsList(
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
@@ -273,62 +373,19 @@ export class AiRouterService {
|
||||
agents: AgentEntity[],
|
||||
workspaceObjectsList: string,
|
||||
): string {
|
||||
return agents
|
||||
const agentDescriptions = agents
|
||||
.map((agent) => {
|
||||
const baseDescription = `- ${agent.label} (${agent.id}): ${agent.description}`;
|
||||
|
||||
if (
|
||||
agent.standardId === DATA_MANIPULATOR_AGENT.standardId &&
|
||||
workspaceObjectsList
|
||||
) {
|
||||
return `${baseDescription}
|
||||
|
||||
Available workspace objects:
|
||||
${workspaceObjectsList}`;
|
||||
}
|
||||
|
||||
return baseDescription;
|
||||
return `- ${agent.label} (${agent.name}): ${agent.description}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
private buildRouterSystemPrompt(agentDescriptions: string): string {
|
||||
return `You are an AI router that decides which agent should handle a user's message.
|
||||
if (workspaceObjectsList) {
|
||||
return `${agentDescriptions}
|
||||
|
||||
Available agents:
|
||||
${agentDescriptions}
|
||||
Available workspace objects for data-manipulator:
|
||||
${workspaceObjectsList}`;
|
||||
}
|
||||
|
||||
Your task is to:
|
||||
1. Select the most appropriate agent
|
||||
2. Identify specific objects mentioned in the query (if any)
|
||||
3. Determine which operations are needed
|
||||
|
||||
For toolHints:
|
||||
- relevantObjects: Extract object names the user is asking about (e.g., if asking about "companies and people", return ["company", "person"])
|
||||
- operations: Array of needed operations from: ["find", "create", "update", "delete"]
|
||||
- "find": for searching, querying, or reading data
|
||||
- "create": for creating new records
|
||||
- "update": for modifying existing records
|
||||
- "delete": for removing records
|
||||
|
||||
Examples:
|
||||
- "Show me all companies" → operations: ["find"]
|
||||
- "Create a task for John" → operations: ["create"]
|
||||
- "Update the company name" → operations: ["find", "update"]
|
||||
|
||||
This helps optimize the agent's tool context by only loading relevant tools.`;
|
||||
}
|
||||
|
||||
private buildRouterUserPrompt(
|
||||
conversationHistory: string,
|
||||
currentMessage: string,
|
||||
): string {
|
||||
return `Conversation history:
|
||||
${conversationHistory || 'No previous conversation'}
|
||||
|
||||
Current user message:
|
||||
${currentMessage}
|
||||
|
||||
Which agent should handle this message?`;
|
||||
return agentDescriptions;
|
||||
}
|
||||
}
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
generateObject,
|
||||
type UIDataTypes,
|
||||
type UIMessage,
|
||||
type UITools,
|
||||
} from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
DEFAULT_SMART_MODEL,
|
||||
type ModelId,
|
||||
} from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
|
||||
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai-models/constants/ai-telemetry.const';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
|
||||
import { type AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
|
||||
import { type ExecutionPlan } from 'src/engine/metadata-modules/ai-router/types/router-result.interface';
|
||||
|
||||
@Injectable()
|
||||
export class AiRouterPlanGeneratorService {
|
||||
private readonly logger = new Logger(AiRouterPlanGeneratorService.name);
|
||||
|
||||
constructor(
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
) {}
|
||||
|
||||
async generatePlan({
|
||||
messages,
|
||||
availableAgents,
|
||||
agentDescriptions,
|
||||
smartModel,
|
||||
}: {
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[];
|
||||
availableAgents: AgentEntity[];
|
||||
agentDescriptions: string;
|
||||
smartModel: ModelId;
|
||||
}): Promise<ExecutionPlan> {
|
||||
const model = this.getSmartModel(smartModel);
|
||||
const agentNames = availableAgents.map((agent) => agent.name);
|
||||
|
||||
const conversationHistory = messages
|
||||
.slice(0, -1)
|
||||
.map((msg) => {
|
||||
const textContent =
|
||||
msg.parts.find((part) => part.type === 'text')?.text || '';
|
||||
|
||||
return `${msg.role}: ${textContent}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const currentMessage =
|
||||
messages[messages.length - 1]?.parts.find((part) => part.type === 'text')
|
||||
?.text || '';
|
||||
|
||||
const systemPrompt = `You are an AI planner that creates execution plans for multi-agent tasks.
|
||||
|
||||
Available agents:
|
||||
${agentDescriptions}
|
||||
|
||||
Create a step-by-step execution plan. Each step should:
|
||||
- Assign to the most appropriate agent
|
||||
- Have a clear, specific task
|
||||
- Specify expected output
|
||||
- List dependencies on previous steps (if any)
|
||||
|
||||
Keep plans focused and efficient.`;
|
||||
|
||||
const userPrompt = `${conversationHistory ? `Conversation history:\n${conversationHistory}\n\n` : ''}Current request:\n${currentMessage}\n\nCreate a detailed execution plan with specific steps.`;
|
||||
|
||||
const planStepSchema = z.object({
|
||||
stepNumber: z.number().describe('Step number in execution order'),
|
||||
agentName: z
|
||||
.enum([agentNames[0], ...agentNames.slice(1)])
|
||||
.describe('Agent name to execute this step'),
|
||||
task: z.string().describe('Specific task for this agent'),
|
||||
expectedOutput: z.string().describe('Expected output from this step'),
|
||||
dependsOn: z
|
||||
.array(z.number())
|
||||
.optional()
|
||||
.describe('Step numbers this step depends on'),
|
||||
});
|
||||
|
||||
const planSchema = z.object({
|
||||
steps: z.array(planStepSchema).describe('Execution steps in order'),
|
||||
reasoning: z.string().describe('Why multi-agent planning is needed'),
|
||||
});
|
||||
|
||||
const PLANNER_TEMPERATURE = 0.1;
|
||||
|
||||
const result = await generateObject({
|
||||
model,
|
||||
system: systemPrompt,
|
||||
prompt: userPrompt,
|
||||
schema: planSchema,
|
||||
temperature: PLANNER_TEMPERATURE,
|
||||
experimental_telemetry: AI_TELEMETRY_CONFIG,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`[PLANNER] Generated plan with ${result.object.steps.length} steps`,
|
||||
);
|
||||
|
||||
this.validatePlan(result.object);
|
||||
|
||||
return result.object as ExecutionPlan;
|
||||
}
|
||||
|
||||
private validatePlan(plan: ExecutionPlan): void {
|
||||
const stepNumbers = new Set(plan.steps.map((s) => s.stepNumber));
|
||||
|
||||
for (const step of plan.steps) {
|
||||
this.validateStepDependencies(step, stepNumbers);
|
||||
}
|
||||
|
||||
this.logger.log(`[PLANNER] Plan validation passed`);
|
||||
}
|
||||
|
||||
private validateStepDependencies(
|
||||
step: { stepNumber: number; dependsOn?: number[] },
|
||||
validStepNumbers: Set<number>,
|
||||
): void {
|
||||
if (!step.dependsOn) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.checkForSelfDependency(step);
|
||||
|
||||
for (const dependency of step.dependsOn) {
|
||||
this.validateDependencyExists(dependency, validStepNumbers);
|
||||
this.validateDependencyOrder(step.stepNumber, dependency);
|
||||
}
|
||||
}
|
||||
|
||||
private checkForSelfDependency(step: {
|
||||
stepNumber: number;
|
||||
dependsOn?: number[];
|
||||
}): void {
|
||||
if (step.dependsOn?.includes(step.stepNumber)) {
|
||||
throw new Error(`Step ${step.stepNumber} cannot depend on itself`);
|
||||
}
|
||||
}
|
||||
|
||||
private validateDependencyExists(
|
||||
dependency: number,
|
||||
validStepNumbers: Set<number>,
|
||||
): void {
|
||||
if (!validStepNumbers.has(dependency)) {
|
||||
throw new Error(`Invalid dependency: step ${dependency} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
private validateDependencyOrder(
|
||||
currentStepNumber: number,
|
||||
dependency: number,
|
||||
): void {
|
||||
if (dependency >= currentStepNumber) {
|
||||
throw new Error(
|
||||
`Step ${currentStepNumber} depends on future step ${dependency}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getSmartModel(modelId: ModelId) {
|
||||
if (modelId === DEFAULT_SMART_MODEL) {
|
||||
return this.getDefaultSmartModel();
|
||||
}
|
||||
|
||||
return this.getSpecificSmartModel(modelId);
|
||||
}
|
||||
|
||||
private getDefaultSmartModel() {
|
||||
const registeredModel =
|
||||
this.aiModelRegistryService.getDefaultPerformanceModel();
|
||||
|
||||
if (!registeredModel) {
|
||||
throw new Error('No smart model available');
|
||||
}
|
||||
|
||||
return registeredModel.model;
|
||||
}
|
||||
|
||||
private getSpecificSmartModel(modelId: ModelId) {
|
||||
const registeredModel = this.aiModelRegistryService.getModel(modelId);
|
||||
|
||||
if (!registeredModel) {
|
||||
throw new Error(`Smart model "${modelId}" not available`);
|
||||
}
|
||||
|
||||
return registeredModel.model;
|
||||
}
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
generateObject,
|
||||
type LanguageModel,
|
||||
type UIDataTypes,
|
||||
type UIMessage,
|
||||
type UITools,
|
||||
} from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
DEFAULT_FAST_MODEL,
|
||||
type ModelId,
|
||||
} from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
|
||||
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai-models/constants/ai-telemetry.const';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
|
||||
import { type AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
|
||||
import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai-agent/constants/agent-system-prompts.const';
|
||||
|
||||
export type StrategyDecision = {
|
||||
strategy: 'simple' | 'planned';
|
||||
agentName?: string;
|
||||
toolHints?: {
|
||||
relevantObjects?: string[];
|
||||
operations?: Array<'find' | 'create' | 'update' | 'delete'>;
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AiRouterStrategyDeciderService {
|
||||
private readonly logger = new Logger(AiRouterStrategyDeciderService.name);
|
||||
|
||||
constructor(
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
) {}
|
||||
|
||||
async decideStrategy({
|
||||
messages,
|
||||
availableAgents,
|
||||
agentDescriptions,
|
||||
fastModel,
|
||||
}: {
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[];
|
||||
availableAgents: AgentEntity[];
|
||||
agentDescriptions: string;
|
||||
fastModel: ModelId;
|
||||
}): Promise<StrategyDecision> {
|
||||
if (availableAgents.length === 1) {
|
||||
return this.createSingleAgentDecision(availableAgents[0]);
|
||||
}
|
||||
|
||||
const model = this.getFastModel(fastModel);
|
||||
const agentNames = availableAgents.map((agent) => agent.name);
|
||||
const conversationHistory = this.buildConversationHistory(messages);
|
||||
const currentMessage = this.extractCurrentMessage(messages);
|
||||
|
||||
const systemPrompt = AGENT_SYSTEM_PROMPTS.ROUTER(agentDescriptions);
|
||||
const userPrompt = this.buildUserPrompt(
|
||||
conversationHistory,
|
||||
currentMessage,
|
||||
);
|
||||
|
||||
const strategySchema = this.buildStrategySchema(agentNames);
|
||||
const decision = await this.generateStrategyDecision(
|
||||
model,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
strategySchema,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`[STRATEGY] Decision: ${JSON.stringify(decision, null, 2)}`,
|
||||
);
|
||||
|
||||
return decision;
|
||||
}
|
||||
|
||||
private createSingleAgentDecision(agent: AgentEntity): StrategyDecision {
|
||||
return {
|
||||
strategy: 'simple',
|
||||
agentName: agent.name,
|
||||
};
|
||||
}
|
||||
|
||||
private buildConversationHistory(
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[],
|
||||
): string {
|
||||
return messages
|
||||
.slice(0, -1)
|
||||
.map((message) => {
|
||||
const textContent =
|
||||
message.parts.find((part) => part.type === 'text')?.text || '';
|
||||
|
||||
return `${message.role}: ${textContent}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
private extractCurrentMessage(
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[],
|
||||
): string {
|
||||
return (
|
||||
messages[messages.length - 1]?.parts.find((part) => part.type === 'text')
|
||||
?.text || ''
|
||||
);
|
||||
}
|
||||
|
||||
private buildStrategySchema(agentNames: string[]) {
|
||||
return z.object({
|
||||
strategy: z
|
||||
.enum(['simple', 'planned'])
|
||||
.describe(
|
||||
'Routing strategy: "simple" for single agent, "planned" for multi-agent coordination',
|
||||
),
|
||||
agentName: z
|
||||
.enum([agentNames[0], ...agentNames.slice(1)])
|
||||
.optional()
|
||||
.describe(
|
||||
'Agent name (REQUIRED if strategy is "simple", omit if "planned")',
|
||||
),
|
||||
toolHints: z
|
||||
.object({
|
||||
relevantObjects: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Names of objects mentioned (e.g., "company", "person")'),
|
||||
operations: z
|
||||
.array(z.enum(['find', 'create', 'update', 'delete']))
|
||||
.optional()
|
||||
.describe('Required database operations'),
|
||||
})
|
||||
.optional()
|
||||
.describe('Tool hints for simple strategy (optional)'),
|
||||
});
|
||||
}
|
||||
|
||||
private async generateStrategyDecision(
|
||||
model: LanguageModel,
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
schema: z.ZodTypeAny,
|
||||
): Promise<StrategyDecision> {
|
||||
const ROUTER_TEMPERATURE = 0.1;
|
||||
|
||||
const result = await generateObject({
|
||||
model,
|
||||
system: systemPrompt,
|
||||
prompt: userPrompt,
|
||||
schema,
|
||||
temperature: ROUTER_TEMPERATURE,
|
||||
experimental_telemetry: AI_TELEMETRY_CONFIG,
|
||||
});
|
||||
|
||||
return result.object as StrategyDecision;
|
||||
}
|
||||
|
||||
private getFastModel(modelId: ModelId) {
|
||||
if (modelId === DEFAULT_FAST_MODEL) {
|
||||
return this.getDefaultFastModel();
|
||||
}
|
||||
|
||||
return this.getSpecificFastModel(modelId);
|
||||
}
|
||||
|
||||
private getDefaultFastModel() {
|
||||
const registeredModel = this.aiModelRegistryService.getDefaultSpeedModel();
|
||||
|
||||
if (!registeredModel) {
|
||||
throw new Error('No fast model available');
|
||||
}
|
||||
|
||||
return registeredModel.model;
|
||||
}
|
||||
|
||||
private getSpecificFastModel(modelId: ModelId) {
|
||||
const registeredModel = this.aiModelRegistryService.getModel(modelId);
|
||||
|
||||
if (!registeredModel) {
|
||||
throw new Error(`Fast model "${modelId}" not available`);
|
||||
}
|
||||
|
||||
return registeredModel.model;
|
||||
}
|
||||
|
||||
private buildUserPrompt(
|
||||
conversationHistory: string,
|
||||
currentMessage: string,
|
||||
): string {
|
||||
return `Conversation history:
|
||||
${conversationHistory || 'No previous conversation'}
|
||||
|
||||
Current user message:
|
||||
${currentMessage}
|
||||
|
||||
Which agent should handle this message?`;
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { type AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
|
||||
|
||||
import { type ToolHints } from './tool-hints.interface';
|
||||
|
||||
export type PlanStep = {
|
||||
stepNumber: number;
|
||||
agentName: string;
|
||||
task: string;
|
||||
expectedOutput: string;
|
||||
dependsOn?: number[];
|
||||
};
|
||||
|
||||
export type ExecutionPlan = {
|
||||
steps: PlanStep[];
|
||||
reasoning: string;
|
||||
};
|
||||
|
||||
export type RouterDebugInfo = {
|
||||
availableAgents: Array<{ id: string; label: string }>;
|
||||
routerModel: string;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
};
|
||||
|
||||
export type SimpleRouterResult = {
|
||||
strategy: 'simple';
|
||||
agent: AgentEntity;
|
||||
toolHints?: ToolHints;
|
||||
debugInfo?: RouterDebugInfo;
|
||||
};
|
||||
|
||||
export type PlannedRouterResult = {
|
||||
strategy: 'planned';
|
||||
plan: ExecutionPlan;
|
||||
debugInfo?: RouterDebugInfo;
|
||||
};
|
||||
|
||||
export type UnifiedRouterResult = SimpleRouterResult | PlannedRouterResult;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ToolAdapterService } from 'src/engine/metadata-modules/ai-tools/services/tool-adapter.service';
|
||||
import { ToolService } from 'src/engine/metadata-modules/ai-tools/services/tool.service';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
import { RecordCrudModule } from 'src/engine/core-modules/record-crud/record-crud.module';
|
||||
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
|
||||
import { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool';
|
||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
|
||||
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
|
||||
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
import { MessagingModule } from 'src/modules/messaging/messaging.module';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([RoleEntity, FileEntity]),
|
||||
FileModule,
|
||||
TokenModule,
|
||||
FeatureFlagModule,
|
||||
RecordCrudModule,
|
||||
ObjectMetadataModule,
|
||||
WorkspacePermissionsCacheModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
UserRoleModule,
|
||||
TwentyORMModule,
|
||||
MessagingModule,
|
||||
PermissionsModule,
|
||||
ToolModule,
|
||||
],
|
||||
providers: [ToolService, ToolAdapterService, SearchArticlesTool],
|
||||
exports: [ToolService, ToolAdapterService, SearchArticlesTool],
|
||||
})
|
||||
export class AiToolsModule {}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { jsonSchema } from 'ai';
|
||||
|
||||
import { ToolAdapterService } from 'src/engine/metadata-modules/ai-tools/services/tool-adapter.service';
|
||||
import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
|
||||
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';
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
|
||||
const createMockToolRegistry = () => ({
|
||||
getAllToolTypes: jest.fn(),
|
||||
getTool: jest.fn(),
|
||||
});
|
||||
|
||||
const createMockPermissions = () => ({
|
||||
hasToolPermission: jest.fn<
|
||||
Promise<boolean>,
|
||||
[RolePermissionConfig, string, PermissionFlagType]
|
||||
>(),
|
||||
});
|
||||
|
||||
describe('ToolAdapterService', () => {
|
||||
let mockRegistry: ReturnType<typeof createMockToolRegistry>;
|
||||
let mockPermissions: ReturnType<typeof createMockPermissions>;
|
||||
let service: ToolAdapterService;
|
||||
|
||||
// Shared tools
|
||||
const unflaggedToolExecute = jest.fn(async (input: ToolInput) => ({
|
||||
success: true,
|
||||
message: 'Tool executed successfully',
|
||||
result: { echoed: input },
|
||||
}));
|
||||
const unflaggedTool: Tool = {
|
||||
description: 'HTTP Request tool',
|
||||
inputSchema: jsonSchema({ type: 'object', properties: {} }),
|
||||
execute: unflaggedToolExecute,
|
||||
};
|
||||
|
||||
const flaggedToolExecute = jest.fn(async (input: ToolInput) => ({
|
||||
success: true,
|
||||
message: 'Tool executed successfully',
|
||||
result: { sent: input },
|
||||
}));
|
||||
const flaggedTool: Tool = {
|
||||
description: 'Send Email tool',
|
||||
inputSchema: jsonSchema({ type: 'object', properties: {} }),
|
||||
execute: flaggedToolExecute,
|
||||
flag: PermissionFlagType.SEND_EMAIL_TOOL,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
mockRegistry = createMockToolRegistry();
|
||||
mockPermissions = createMockPermissions();
|
||||
|
||||
// Setup mock tool responses
|
||||
mockRegistry.getAllToolTypes.mockReturnValue([
|
||||
ToolType.HTTP_REQUEST,
|
||||
ToolType.SEND_EMAIL,
|
||||
]);
|
||||
mockRegistry.getTool.mockImplementation((type: ToolType) => {
|
||||
if (type === ToolType.HTTP_REQUEST) return unflaggedTool;
|
||||
if (type === ToolType.SEND_EMAIL) return flaggedTool;
|
||||
throw new Error('Tool not found in mock');
|
||||
});
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
ToolAdapterService,
|
||||
{
|
||||
provide: ToolRegistryService,
|
||||
useValue: mockRegistry,
|
||||
},
|
||||
{
|
||||
provide: PermissionsService,
|
||||
useValue: mockPermissions,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = moduleRef.get(ToolAdapterService);
|
||||
});
|
||||
|
||||
it('should include unflagged tools regardless of role/workspace', async () => {
|
||||
const toolsNoContext = await service.getTools();
|
||||
|
||||
expect(Object.keys(toolsNoContext)).toContain('http_request');
|
||||
|
||||
const toolsWithPartialContext = await service.getTools({
|
||||
unionOf: ['role-1'],
|
||||
});
|
||||
|
||||
expect(Object.keys(toolsWithPartialContext)).toContain('http_request');
|
||||
});
|
||||
|
||||
it('should not include flagged tools when role/workspace are missing', async () => {
|
||||
const toolsNoContext = await service.getTools();
|
||||
|
||||
expect(Object.keys(toolsNoContext)).not.toContain('send_email');
|
||||
|
||||
const toolsRoleOnly = await service.getTools({
|
||||
unionOf: ['role-1'],
|
||||
});
|
||||
|
||||
expect(Object.keys(toolsRoleOnly)).not.toContain('send_email');
|
||||
|
||||
const toolsWorkspaceOnly = await service.getTools(undefined, 'ws-1');
|
||||
|
||||
expect(Object.keys(toolsWorkspaceOnly)).not.toContain('send_email');
|
||||
});
|
||||
|
||||
it('should include flagged tools when permission is granted', async () => {
|
||||
mockPermissions.hasToolPermission.mockResolvedValueOnce(true);
|
||||
|
||||
const tools = await service.getTools({ unionOf: ['role-1'] }, 'ws-1');
|
||||
|
||||
expect(mockPermissions.hasToolPermission).toHaveBeenCalledWith(
|
||||
{ unionOf: ['role-1'] },
|
||||
'ws-1',
|
||||
PermissionFlagType.SEND_EMAIL_TOOL,
|
||||
);
|
||||
|
||||
expect(Object.keys(tools)).toContain('send_email');
|
||||
});
|
||||
|
||||
it('should exclude flagged tools when permission is denied', async () => {
|
||||
mockPermissions.hasToolPermission.mockResolvedValueOnce(false);
|
||||
|
||||
const tools = await service.getTools({ unionOf: ['role-1'] }, 'ws-1');
|
||||
|
||||
expect(Object.keys(tools)).not.toContain('send_email');
|
||||
});
|
||||
|
||||
it('should lowercase tool type keys in the returned ToolSet', async () => {
|
||||
const tools = await service.getTools();
|
||||
|
||||
const keys = Object.keys(tools);
|
||||
|
||||
expect(keys).toContain('http_request');
|
||||
expect(keys).not.toContain(ToolType.HTTP_REQUEST); // ensure enum raw value not used as-is
|
||||
});
|
||||
|
||||
it('should forward execute input correctly and return underlying result', async () => {
|
||||
const tools = await service.getTools();
|
||||
|
||||
const input = { url: 'https://example.com', method: 'GET' } as ToolInput;
|
||||
const result = await tools['http_request'].execute?.(
|
||||
{ input },
|
||||
{
|
||||
toolCallId: 'test-tool-call-id',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'content',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
// Ensure wrapper forwards only parameters.input
|
||||
expect(unflaggedToolExecute).toHaveBeenCalledWith(input);
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
message: 'Tool executed successfully',
|
||||
result: { echoed: input },
|
||||
});
|
||||
});
|
||||
});
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { ToolService } from 'src/engine/metadata-modules/ai-tools/services/tool.service';
|
||||
import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service';
|
||||
import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service';
|
||||
import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
|
||||
import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
|
||||
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
|
||||
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
|
||||
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.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';
|
||||
|
||||
// Minimal mock repository type
|
||||
const createMockRepository = () => ({
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
update: jest.fn(),
|
||||
softDelete: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
});
|
||||
|
||||
describe('ToolService', () => {
|
||||
const workspaceId = 'ws_1';
|
||||
const roleId = 'role_1';
|
||||
|
||||
let service: ToolService;
|
||||
let permissionsCacheService: WorkspacePermissionsCacheService;
|
||||
|
||||
const testObject = getMockObjectMetadataEntity({
|
||||
workspaceId: '',
|
||||
id: 'obj_1',
|
||||
nameSingular: 'testObject',
|
||||
namePlural: 'testObjects',
|
||||
labelSingular: 'Test Object',
|
||||
labelPlural: 'Test Objects',
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
fields: [],
|
||||
});
|
||||
|
||||
const mockRepo = createMockRepository();
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
ToolService,
|
||||
{
|
||||
provide: TwentyORMGlobalManager,
|
||||
useValue: {
|
||||
getRepositoryForWorkspace: jest.fn().mockResolvedValue(mockRepo),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ObjectMetadataServiceV2,
|
||||
useValue: {
|
||||
findManyWithinWorkspace: jest.fn().mockResolvedValue([testObject]),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkspacePermissionsCacheService,
|
||||
useValue: {
|
||||
getRolesPermissionsFromCache: jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
[roleId]: {
|
||||
[testObject.id]: {
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
canDestroyObjectRecords: false,
|
||||
restrictedFields: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: RecordInputTransformerService,
|
||||
useValue: {
|
||||
process: jest.fn(async ({ recordInput }) => recordInput),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceCacheStorageService,
|
||||
useValue: {
|
||||
getObjectMetadataMapsOrThrow: jest.fn().mockResolvedValue({
|
||||
byId: {
|
||||
[testObject.id]: {
|
||||
...testObject,
|
||||
fieldsById: {},
|
||||
fieldIdByJoinColumnName: {},
|
||||
fieldIdByName: {},
|
||||
indexMetadatas: [],
|
||||
},
|
||||
},
|
||||
idByNameSingular: { [testObject.nameSingular]: testObject.id },
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CreateRecordService,
|
||||
useValue: { execute: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: UpdateRecordService,
|
||||
useValue: { execute: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: DeleteRecordService,
|
||||
useValue: { execute: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: FindRecordsService,
|
||||
useValue: { execute: jest.fn() },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = moduleRef.get(ToolService);
|
||||
permissionsCacheService = moduleRef.get(WorkspacePermissionsCacheService);
|
||||
});
|
||||
|
||||
describe('listTools', () => {
|
||||
it('should return tools based on role permissions', async () => {
|
||||
const tools = await service.listTools({ unionOf: [roleId] }, workspaceId);
|
||||
|
||||
expect(
|
||||
permissionsCacheService.getRolesPermissionsFromCache,
|
||||
).toHaveBeenCalledWith({ workspaceId });
|
||||
|
||||
// Verify tool keys
|
||||
expect(tools['create_testObject']).toBeDefined();
|
||||
expect(tools['update_testObject']).toBeDefined();
|
||||
expect(tools['find_testObject']).toBeDefined();
|
||||
expect(tools['soft_delete_testObject']).toBeDefined();
|
||||
expect(tools['soft_delete_many_testObject']).toBeDefined();
|
||||
|
||||
// Ensure the execute functions are wired
|
||||
expect(typeof tools['create_testObject'].execute).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('softDeleteManyRecords', () => {
|
||||
it('should error when filter is invalid', async () => {
|
||||
const result = await (service as any).softDeleteManyRecords(
|
||||
'testObject',
|
||||
{},
|
||||
workspaceId,
|
||||
roleId,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe(
|
||||
'Filter with record IDs is required for bulk soft delete',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type ToolSet } from 'ai';
|
||||
|
||||
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 { type PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
|
||||
@Injectable()
|
||||
export class ToolAdapterService {
|
||||
constructor(
|
||||
private readonly toolRegistry: ToolRegistryService,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
) {}
|
||||
|
||||
async getTools(
|
||||
rolePermissionConfig?: RolePermissionConfig,
|
||||
workspaceId?: string,
|
||||
): Promise<ToolSet> {
|
||||
const tools: ToolSet = {};
|
||||
|
||||
for (const toolType of this.toolRegistry.getAllToolTypes()) {
|
||||
const tool = this.toolRegistry.getTool(toolType);
|
||||
|
||||
if (!tool.flag) {
|
||||
tools[toolType.toLowerCase()] = this.createToolSet(tool);
|
||||
} else if (rolePermissionConfig && workspaceId) {
|
||||
const hasPermission = await this.permissionsService.hasToolPermission(
|
||||
rolePermissionConfig,
|
||||
workspaceId,
|
||||
tool.flag as PermissionFlagType,
|
||||
);
|
||||
|
||||
if (hasPermission) {
|
||||
tools[toolType.toLowerCase()] = this.createToolSet(tool);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
private createToolSet(tool: Tool) {
|
||||
return {
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
execute: async (parameters: { input: ToolInput }) =>
|
||||
tool.execute(parameters.input),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { type ToolSet } from 'ai';
|
||||
import { type ActorMetadata } from 'twenty-shared/types';
|
||||
|
||||
import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service';
|
||||
import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service';
|
||||
import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
|
||||
import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
|
||||
import { generateCreateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-create-record-input-schema.util';
|
||||
import { generateUpdateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-update-record-input-schema.util';
|
||||
import { BulkDeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/bulk-delete-tool.zod-schema';
|
||||
import { generateFindToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema';
|
||||
import { SoftDeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/soft-delete-tool.zod-schema';
|
||||
import { FindOneToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-one-tool.zod-schema';
|
||||
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai-agent/utils/is-workflow-related-object.util';
|
||||
import {
|
||||
type ToolHints,
|
||||
type ToolOperation,
|
||||
} from 'src/engine/metadata-modules/ai-router/types/tool-hints.interface';
|
||||
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
|
||||
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util';
|
||||
|
||||
@Injectable()
|
||||
export class ToolService {
|
||||
private readonly logger = new Logger(ToolService.name);
|
||||
|
||||
constructor(
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
private readonly objectMetadataService: ObjectMetadataServiceV2,
|
||||
protected readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
|
||||
private readonly createRecordService: CreateRecordService,
|
||||
private readonly updateRecordService: UpdateRecordService,
|
||||
private readonly deleteRecordService: DeleteRecordService,
|
||||
private readonly findRecordsService: FindRecordsService,
|
||||
) {}
|
||||
|
||||
// Generates AI tools for database operations based on workspace objects and permissions
|
||||
// Supports filtering by object names and operation types via toolHints
|
||||
// Returns a map of tool names to tool definitions
|
||||
async listTools(
|
||||
rolePermissionConfig: RolePermissionConfig,
|
||||
workspaceId: string,
|
||||
actorContext?: ActorMetadata,
|
||||
toolHints?: ToolHints,
|
||||
): Promise<ToolSet> {
|
||||
const tools: ToolSet = {};
|
||||
|
||||
const { data: rolesPermissions } =
|
||||
await this.workspacePermissionsCacheService.getRolesPermissionsFromCache({
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
let objectPermissions;
|
||||
|
||||
if ('unionOf' in rolePermissionConfig) {
|
||||
if (rolePermissionConfig.unionOf.length === 1) {
|
||||
objectPermissions = rolesPermissions[rolePermissionConfig.unionOf[0]];
|
||||
} else {
|
||||
// TODO: Implement union logic for multiple roles
|
||||
throw new Error(
|
||||
'Union permission logic for multiple roles not yet implemented',
|
||||
);
|
||||
}
|
||||
} else if ('intersectionOf' in rolePermissionConfig) {
|
||||
const allRolePermissions = rolePermissionConfig.intersectionOf.map(
|
||||
(roleId: string) => rolesPermissions[roleId],
|
||||
);
|
||||
|
||||
objectPermissions =
|
||||
allRolePermissions.length === 1
|
||||
? allRolePermissions[0]
|
||||
: computePermissionIntersection(allRolePermissions);
|
||||
} else {
|
||||
return tools;
|
||||
}
|
||||
|
||||
const allObjectMetadata =
|
||||
await this.objectMetadataService.findManyWithinWorkspace(workspaceId, {
|
||||
where: {
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
},
|
||||
relations: ['fields'],
|
||||
});
|
||||
|
||||
let filteredObjectMetadata = allObjectMetadata.filter(
|
||||
(objectMetadata) => !isWorkflowRelatedObject(objectMetadata),
|
||||
);
|
||||
|
||||
if (toolHints?.relevantObjects && toolHints.relevantObjects.length > 0) {
|
||||
const relevantSet = new Set(toolHints.relevantObjects);
|
||||
const originalCount = filteredObjectMetadata.length;
|
||||
|
||||
filteredObjectMetadata = filteredObjectMetadata.filter(
|
||||
(obj) =>
|
||||
relevantSet.has(obj.nameSingular) || relevantSet.has(obj.namePlural),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Tool filtering: reduced from ${originalCount} to ${filteredObjectMetadata.length} objects based on hints: ${toolHints.relevantObjects.join(', ')}`,
|
||||
);
|
||||
|
||||
if (filteredObjectMetadata.length === 0) {
|
||||
this.logger.warn(
|
||||
`Tool filtering resulted in 0 objects. Hints may be incorrect: ${toolHints.relevantObjects.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const operationsSet = toolHints?.operations
|
||||
? new Set(toolHints.operations)
|
||||
: null;
|
||||
|
||||
const shouldIncludeOperation = (operation: ToolOperation) =>
|
||||
!operationsSet || operationsSet.has(operation);
|
||||
|
||||
const shouldIncludeFind = shouldIncludeOperation('find');
|
||||
const shouldIncludeCreate = shouldIncludeOperation('create');
|
||||
const shouldIncludeUpdate = shouldIncludeOperation('update');
|
||||
const shouldIncludeDelete = shouldIncludeOperation('delete');
|
||||
|
||||
filteredObjectMetadata.forEach((objectMetadata) => {
|
||||
const objectPermission = objectPermissions[objectMetadata.id];
|
||||
|
||||
if (!objectPermission) {
|
||||
return;
|
||||
}
|
||||
|
||||
const restrictedFields = objectPermission.restrictedFields;
|
||||
|
||||
if (shouldIncludeFind && objectPermission.canReadObjectRecords) {
|
||||
tools[`find_${objectMetadata.nameSingular}`] = {
|
||||
description: `Search for ${objectMetadata.labelSingular} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination and orderBy for sorting. To find by ID, use filter: { id: { eq: "record-id" } }. Returns an array of matching records with their full data.`,
|
||||
inputSchema: generateFindToolInputSchema(
|
||||
objectMetadata,
|
||||
restrictedFields,
|
||||
),
|
||||
execute: async (parameters) => {
|
||||
const { limit, offset, orderBy, ...filter } = parameters.input;
|
||||
|
||||
return this.findRecordsService.execute({
|
||||
objectName: objectMetadata.nameSingular,
|
||||
filter,
|
||||
orderBy,
|
||||
limit,
|
||||
offset,
|
||||
workspaceId,
|
||||
rolePermissionConfig,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
tools[`find_one_${objectMetadata.nameSingular}`] = {
|
||||
description: `Retrieve a single ${objectMetadata.labelSingular} record by its unique ID. Use this when you know the exact record ID and need the complete record data. Returns the full record or an error if not found.`,
|
||||
inputSchema: FindOneToolInputSchema,
|
||||
execute: async (parameters) => {
|
||||
return this.findRecordsService.execute({
|
||||
objectName: objectMetadata.nameSingular,
|
||||
filter: { id: { eq: parameters.input.id } },
|
||||
limit: 1,
|
||||
workspaceId,
|
||||
rolePermissionConfig,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (objectPermission.canUpdateObjectRecords) {
|
||||
if (shouldIncludeCreate) {
|
||||
tools[`create_${objectMetadata.nameSingular}`] = {
|
||||
description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`,
|
||||
inputSchema: generateCreateRecordInputSchema(
|
||||
objectMetadata,
|
||||
restrictedFields,
|
||||
),
|
||||
execute: async (parameters) => {
|
||||
return this.createRecordService.execute({
|
||||
objectName: objectMetadata.nameSingular,
|
||||
objectRecord: parameters.input,
|
||||
workspaceId,
|
||||
rolePermissionConfig,
|
||||
createdBy: actorContext,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (shouldIncludeUpdate) {
|
||||
tools[`update_${objectMetadata.nameSingular}`] = {
|
||||
description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`,
|
||||
inputSchema: generateUpdateRecordInputSchema(
|
||||
objectMetadata,
|
||||
restrictedFields,
|
||||
),
|
||||
execute: async (parameters) => {
|
||||
const { id, ...allFields } = parameters.input;
|
||||
|
||||
const objectRecord = Object.fromEntries(
|
||||
Object.entries(allFields).filter(
|
||||
([, value]) => value !== undefined,
|
||||
),
|
||||
);
|
||||
|
||||
return this.updateRecordService.execute({
|
||||
objectName: objectMetadata.nameSingular,
|
||||
objectRecordId: id,
|
||||
objectRecord,
|
||||
workspaceId,
|
||||
rolePermissionConfig,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldIncludeDelete && objectPermission.canSoftDeleteObjectRecords) {
|
||||
tools[`soft_delete_${objectMetadata.nameSingular}`] = {
|
||||
description: `Soft delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record remains in the database but is hidden from normal queries. This is reversible and preserves all data. Use this for temporary removal.`,
|
||||
inputSchema: SoftDeleteToolInputSchema,
|
||||
execute: async (parameters) => {
|
||||
return this.deleteRecordService.execute({
|
||||
objectName: objectMetadata.nameSingular,
|
||||
objectRecordId: parameters.input.id,
|
||||
workspaceId,
|
||||
rolePermissionConfig,
|
||||
soft: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
tools[`soft_delete_many_${objectMetadata.nameSingular}`] = {
|
||||
description: `Soft delete multiple ${objectMetadata.labelSingular} records at once by providing an array of record IDs. All records are marked as deleted but remain in the database. This is efficient for bulk operations and preserves all data.`,
|
||||
inputSchema: BulkDeleteToolInputSchema,
|
||||
execute: async (parameters) => {
|
||||
return this.softDeleteManyRecords(
|
||||
objectMetadata.nameSingular,
|
||||
parameters.input,
|
||||
workspaceId,
|
||||
rolePermissionConfig,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
if (operationsSet) {
|
||||
this.logger.log(
|
||||
`Tool filtering: included operations [${Array.from(operationsSet).join(', ')}]`,
|
||||
);
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
private async softDeleteManyRecords(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
rolePermissionConfig: RolePermissionConfig,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
rolePermissionConfig,
|
||||
);
|
||||
|
||||
const { filter } = parameters;
|
||||
|
||||
if (!filter || typeof filter !== 'object' || !('id' in filter)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to soft delete many ${objectName}: Filter with record IDs is required`,
|
||||
error: 'Filter with record IDs is required for bulk soft delete',
|
||||
};
|
||||
}
|
||||
|
||||
const idFilter = filter.id as Record<string, unknown>;
|
||||
const recordIds = idFilter.in;
|
||||
|
||||
if (!Array.isArray(recordIds) || recordIds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecords = await repository.find({
|
||||
where: { id: { in: recordIds } },
|
||||
});
|
||||
|
||||
if (existingRecords.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to soft delete many ${objectName}: No records found with the provided IDs`,
|
||||
error: 'No records found to soft delete',
|
||||
};
|
||||
}
|
||||
|
||||
await repository.softDelete({ id: { in: recordIds } });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully soft deleted ${existingRecords.length} ${objectName} records`,
|
||||
result: {
|
||||
count: existingRecords.length,
|
||||
deletedIds: recordIds,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to soft delete many ${objectName}`,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -1,10 +1,8 @@
|
||||
import { type AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
import { type AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
|
||||
import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity.type';
|
||||
|
||||
export const agentEntityRelationProperties = [
|
||||
'workspace',
|
||||
'outgoingHandoffs',
|
||||
'incomingHandoffs',
|
||||
'application',
|
||||
] as const;
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { type AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
import { type AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
|
||||
import { type FlatAgent } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
|
||||
|
||||
export const transformAgentEntityToFlatAgent = (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module';
|
||||
import { AiAgentModule } from 'src/engine/metadata-modules/ai-agent/ai-agent.module';
|
||||
import { AiChatModule } from 'src/engine/metadata-modules/ai-chat/ai-chat.module';
|
||||
import { CronTriggerModule } from 'src/engine/metadata-modules/cron-trigger/cron-trigger.module';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { DatabaseEventTriggerModule } from 'src/engine/metadata-modules/database-event-trigger/database-event-trigger.module';
|
||||
@@ -25,7 +26,8 @@ import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-
|
||||
SearchFieldMetadataModule,
|
||||
ServerlessFunctionModule,
|
||||
ServerlessFunctionLayerModule,
|
||||
AgentModule,
|
||||
AiAgentModule,
|
||||
AiChatModule,
|
||||
ViewModule,
|
||||
WorkspaceMetadataVersionModule,
|
||||
WorkspaceMigrationModule,
|
||||
@@ -43,7 +45,8 @@ import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-
|
||||
ObjectMetadataModule,
|
||||
SearchFieldMetadataModule,
|
||||
ServerlessFunctionModule,
|
||||
AgentModule,
|
||||
AiAgentModule,
|
||||
AiChatModule,
|
||||
ViewModule,
|
||||
RemoteServerModule,
|
||||
RoleModule,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Relation } from 'typeorm';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { WorkspaceMemberDTO } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
|
||||
import { AgentDTO } from 'src/engine/metadata-modules/agent/dtos/agent.dto';
|
||||
import { AgentDTO } from 'src/engine/metadata-modules/ai-agent/dtos/agent.dto';
|
||||
import { FieldPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/field-permission.dto';
|
||||
import { ObjectPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/object-permission.dto';
|
||||
import { PermissionFlagDTO } from 'src/engine/metadata-modules/permission-flag/dtos/permission-flag.dto';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AgentRoleModule } from 'src/engine/metadata-modules/agent-role/agent-role.module';
|
||||
import { AiAgentRoleModule } from 'src/engine/metadata-modules/ai-agent-role/ai-agent-role.module';
|
||||
import { ObjectPermissionModule } from 'src/engine/metadata-modules/object-permission/object-permission.module';
|
||||
import { PermissionFlagModule } from 'src/engine/metadata-modules/permission-flag/permission-flag.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
@@ -23,7 +23,7 @@ import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/wor
|
||||
TypeOrmModule.forFeature([RoleEntity, RoleTargetsEntity]),
|
||||
TypeOrmModule.forFeature([UserWorkspaceEntity, WorkspaceEntity]),
|
||||
UserRoleModule,
|
||||
AgentRoleModule,
|
||||
AiAgentRoleModule,
|
||||
ApiKeyModule,
|
||||
PermissionsModule,
|
||||
UserWorkspaceModule,
|
||||
|
||||
@@ -24,8 +24,8 @@ import { RequireFeatureFlag } from 'src/engine/guards/feature-flag.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { AgentRoleService } from 'src/engine/metadata-modules/agent-role/agent-role.service';
|
||||
import { AgentDTO } from 'src/engine/metadata-modules/agent/dtos/agent.dto';
|
||||
import { AiAgentRoleService } from 'src/engine/metadata-modules/ai-agent-role/ai-agent-role.service';
|
||||
import { AgentDTO } from 'src/engine/metadata-modules/ai-agent/dtos/agent.dto';
|
||||
import { FieldPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/field-permission.dto';
|
||||
import { ObjectPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/object-permission.dto';
|
||||
import { UpsertFieldPermissionsInput } from 'src/engine/metadata-modules/object-permission/dtos/upsert-field-permissions.input';
|
||||
@@ -69,7 +69,7 @@ export class RoleResolver {
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
private readonly objectPermissionService: ObjectPermissionService,
|
||||
private readonly settingPermissionService: PermissionFlagService,
|
||||
private readonly agentRoleService: AgentRoleService,
|
||||
private readonly agentRoleService: AiAgentRoleService,
|
||||
private readonly apiKeyRoleService: ApiKeyRoleService,
|
||||
private readonly fieldPermissionService: FieldPermissionService,
|
||||
) {}
|
||||
|
||||
Reference in New Issue
Block a user