Files
twenty/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts
T
Félix Malfait e7ebf51e50 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
2025-11-25 12:10:14 +01:00

330 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { msg } from '@lingui/core/macro';
import {
ActorMetadata,
EmailsMetadata,
FieldMetadataType,
PhonesMetadata,
RelationOnDeleteAction,
type FullNameMetadata,
type LinksMetadata,
} from 'twenty-shared/types';
import { STANDARD_OBJECT_IDS } from 'twenty-shared/metadata';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/search-field-metadata/constants/search-vector-field.constants';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceDuplicateCriteria } from 'src/engine/twenty-orm/decorators/workspace-duplicate-criteria.decorator';
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator';
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
import { WorkspaceIsDeprecated } from 'src/engine/twenty-orm/decorators/workspace-is-deprecated.decorator';
import { WorkspaceIsFieldUIReadOnly } from 'src/engine/twenty-orm/decorators/workspace-is-field-ui-readonly.decorator';
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
import { WorkspaceIsSearchable } from 'src/engine/twenty-orm/decorators/workspace-is-searchable.decorator';
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
import { WorkspaceIsUnique } from 'src/engine/twenty-orm/decorators/workspace-is-unique.decorator';
import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-join-column.decorator';
import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator';
import { PERSON_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-icons';
import {
getTsVectorColumnExpressionFromFields,
type FieldTypeAndNameMetadata,
} from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util';
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity';
import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
import { MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity';
import { NoteTargetWorkspaceEntity } from 'src/modules/note/standard-objects/note-target.workspace-entity';
import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity';
import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/task-target.workspace-entity';
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
const NAME_FIELD_NAME = 'name';
const EMAILS_FIELD_NAME = 'emails';
const PHONES_FIELD_NAME = 'phones';
const JOB_TITLE_FIELD_NAME = 'jobTitle';
export const SEARCH_FIELDS_FOR_PERSON: FieldTypeAndNameMetadata[] = [
{ name: NAME_FIELD_NAME, type: FieldMetadataType.FULL_NAME },
{ name: EMAILS_FIELD_NAME, type: FieldMetadataType.EMAILS },
{ name: PHONES_FIELD_NAME, type: FieldMetadataType.PHONES },
{ name: JOB_TITLE_FIELD_NAME, type: FieldMetadataType.TEXT },
];
@WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.person,
namePlural: 'people',
labelSingular: msg`Person`,
labelPlural: msg`People`,
description: msg`A person`,
icon: STANDARD_OBJECT_ICONS.person,
shortcut: 'P',
labelIdentifierStandardId: PERSON_STANDARD_FIELD_IDS.name,
imageIdentifierStandardId: PERSON_STANDARD_FIELD_IDS.avatarUrl,
})
@WorkspaceDuplicateCriteria([
['nameFirstName', 'nameLastName'],
['linkedinLinkPrimaryLinkUrl'],
['emailsPrimaryEmail'],
])
@WorkspaceIsSearchable()
export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceField({
standardId: PERSON_STANDARD_FIELD_IDS.name,
type: FieldMetadataType.FULL_NAME,
label: msg`Name`,
description: msg`Contacts name`,
icon: 'IconUser',
})
@WorkspaceIsNullable()
name: FullNameMetadata | null;
@WorkspaceField({
standardId: PERSON_STANDARD_FIELD_IDS.emails,
type: FieldMetadataType.EMAILS,
label: msg`Emails`,
description: msg`Contacts Emails`,
icon: 'IconMail',
settings: {
maxNumberOfValues: 1,
},
})
@WorkspaceIsUnique()
@WorkspaceIsNullable()
emails: EmailsMetadata;
@WorkspaceField({
standardId: PERSON_STANDARD_FIELD_IDS.linkedinLink,
type: FieldMetadataType.LINKS,
label: msg`Linkedin`,
description: msg`Contacts Linkedin account`,
icon: 'IconBrandLinkedin',
})
@WorkspaceIsNullable()
linkedinLink: LinksMetadata | null;
@WorkspaceField({
standardId: PERSON_STANDARD_FIELD_IDS.xLink,
type: FieldMetadataType.LINKS,
label: msg`X`,
description: msg`Contacts X/Twitter account`,
icon: 'IconBrandX',
})
@WorkspaceIsNullable()
xLink: LinksMetadata | null;
@WorkspaceField({
standardId: PERSON_STANDARD_FIELD_IDS.jobTitle,
type: FieldMetadataType.TEXT,
label: msg`Job Title`,
description: msg`Contacts job title`,
icon: 'IconBriefcase',
})
@WorkspaceIsNullable()
jobTitle: string;
@WorkspaceField({
standardId: PERSON_STANDARD_FIELD_IDS.phone,
type: FieldMetadataType.TEXT,
label: msg`Phone`,
description: msg`Contacts phone number`,
icon: 'IconPhone',
})
@WorkspaceIsDeprecated()
phone: string;
@WorkspaceField({
standardId: PERSON_STANDARD_FIELD_IDS.phones,
type: FieldMetadataType.PHONES,
label: msg`Phones`,
description: msg`Contacts phone numbers`,
icon: 'IconPhone',
settings: {
maxNumberOfValues: 1,
},
})
@WorkspaceIsNullable()
phones: PhonesMetadata;
@WorkspaceField({
standardId: PERSON_STANDARD_FIELD_IDS.city,
type: FieldMetadataType.TEXT,
label: msg`City`,
description: msg`Contacts city`,
icon: 'IconMap',
})
@WorkspaceIsNullable()
city: string;
@WorkspaceField({
standardId: PERSON_STANDARD_FIELD_IDS.avatarUrl,
type: FieldMetadataType.TEXT,
label: msg`Avatar`,
description: msg`Contacts avatar`,
icon: 'IconFileUpload',
})
@WorkspaceIsSystem()
@WorkspaceIsNullable()
avatarUrl: string;
@WorkspaceField({
standardId: PERSON_STANDARD_FIELD_IDS.position,
type: FieldMetadataType.POSITION,
label: msg`Position`,
description: msg`Person record Position`,
icon: 'IconHierarchy2',
defaultValue: 0,
})
@WorkspaceIsSystem()
position: number;
@WorkspaceField({
standardId: PERSON_STANDARD_FIELD_IDS.createdBy,
type: FieldMetadataType.ACTOR,
label: msg`Created by`,
icon: 'IconCreativeCommonsSa',
description: msg`The creator of the record`,
})
@WorkspaceIsFieldUIReadOnly()
createdBy: ActorMetadata;
// Relations
@WorkspaceRelation({
standardId: PERSON_STANDARD_FIELD_IDS.company,
type: RelationType.MANY_TO_ONE,
label: msg`Company`,
description: msg`Contacts company`,
icon: 'IconBuildingSkyscraper',
inverseSideTarget: () => CompanyWorkspaceEntity,
inverseSideFieldKey: 'people',
onDelete: RelationOnDeleteAction.SET_NULL,
})
@WorkspaceIsNullable()
company: Relation<CompanyWorkspaceEntity> | null;
@WorkspaceJoinColumn('company')
companyId: string | null;
@WorkspaceRelation({
standardId: PERSON_STANDARD_FIELD_IDS.pointOfContactForOpportunities,
type: RelationType.ONE_TO_MANY,
label: msg`Opportunities`,
description: msg`List of opportunities for which that person is the point of contact`,
icon: 'IconTargetArrow',
inverseSideTarget: () => OpportunityWorkspaceEntity,
inverseSideFieldKey: 'pointOfContact',
onDelete: RelationOnDeleteAction.SET_NULL,
})
pointOfContactForOpportunities: Relation<OpportunityWorkspaceEntity[]>;
@WorkspaceRelation({
standardId: PERSON_STANDARD_FIELD_IDS.taskTargets,
type: RelationType.ONE_TO_MANY,
label: msg`Tasks`,
description: msg`Tasks tied to the contact`,
icon: 'IconCheckbox',
inverseSideTarget: () => TaskTargetWorkspaceEntity,
onDelete: RelationOnDeleteAction.CASCADE,
})
@WorkspaceIsFieldUIReadOnly()
taskTargets: Relation<TaskTargetWorkspaceEntity[]>;
@WorkspaceRelation({
standardId: PERSON_STANDARD_FIELD_IDS.noteTargets,
type: RelationType.ONE_TO_MANY,
label: msg`Notes`,
description: msg`Notes tied to the contact`,
icon: 'IconNotes',
inverseSideTarget: () => NoteTargetWorkspaceEntity,
onDelete: RelationOnDeleteAction.CASCADE,
})
@WorkspaceIsFieldUIReadOnly()
noteTargets: Relation<NoteTargetWorkspaceEntity[]>;
@WorkspaceRelation({
standardId: PERSON_STANDARD_FIELD_IDS.favorites,
type: RelationType.ONE_TO_MANY,
label: msg`Favorites`,
description: msg`Favorites linked to the contact`,
icon: 'IconHeart',
inverseSideTarget: () => FavoriteWorkspaceEntity,
onDelete: RelationOnDeleteAction.CASCADE,
})
@WorkspaceIsSystem()
favorites: Relation<FavoriteWorkspaceEntity[]>;
@WorkspaceRelation({
standardId: PERSON_STANDARD_FIELD_IDS.attachments,
type: RelationType.ONE_TO_MANY,
label: msg`Attachments`,
description: msg`Attachments linked to the contact.`,
icon: 'IconFileImport',
inverseSideTarget: () => AttachmentWorkspaceEntity,
onDelete: RelationOnDeleteAction.CASCADE,
})
@WorkspaceIsSystem()
attachments: Relation<AttachmentWorkspaceEntity[]>;
@WorkspaceRelation({
standardId: PERSON_STANDARD_FIELD_IDS.messageParticipants,
type: RelationType.ONE_TO_MANY,
label: msg`Message Participants`,
description: msg`Message Participants`,
icon: 'IconUserCircle',
inverseSideTarget: () => MessageParticipantWorkspaceEntity,
inverseSideFieldKey: 'person',
onDelete: RelationOnDeleteAction.SET_NULL,
})
@WorkspaceIsSystem()
messageParticipants: Relation<MessageParticipantWorkspaceEntity[]>;
@WorkspaceRelation({
standardId: PERSON_STANDARD_FIELD_IDS.calendarEventParticipants,
type: RelationType.ONE_TO_MANY,
label: msg`Calendar Event Participants`,
description: msg`Calendar Event Participants`,
icon: 'IconCalendar',
inverseSideTarget: () => CalendarEventParticipantWorkspaceEntity,
onDelete: RelationOnDeleteAction.SET_NULL,
})
@WorkspaceIsSystem()
calendarEventParticipants: Relation<
CalendarEventParticipantWorkspaceEntity[]
>;
@WorkspaceRelation({
standardId: PERSON_STANDARD_FIELD_IDS.timelineActivities,
type: RelationType.ONE_TO_MANY,
label: msg`Events`,
description: msg`Events linked to the person`,
icon: 'IconTimelineEvent',
inverseSideTarget: () => TimelineActivityWorkspaceEntity,
onDelete: RelationOnDeleteAction.CASCADE,
})
@WorkspaceIsNullable()
@WorkspaceIsSystem()
timelineActivities: Relation<TimelineActivityWorkspaceEntity[]>;
@WorkspaceField({
standardId: PERSON_STANDARD_FIELD_IDS.searchVector,
type: FieldMetadataType.TS_VECTOR,
label: SEARCH_VECTOR_FIELD.label,
description: SEARCH_VECTOR_FIELD.description,
icon: 'IconUser',
generatedType: 'STORED',
asExpression: getTsVectorColumnExpressionFromFields(
SEARCH_FIELDS_FOR_PERSON,
),
})
@WorkspaceIsNullable()
@WorkspaceIsSystem()
@WorkspaceFieldIndex({ indexType: IndexType.GIN })
searchVector: string;
}