Fix: AI Agent tool errors and relation field handling (#15668)
### Problems Fixed
1. **Tool execution errors broke conversations**
- Failed tool executions showed "Processing..." indefinitely instead of
error messages
- Tool errors with `input: null` caused subsequent messages to fail with
`Missing required parameter: 'input[X].arguments'`
2. **Relation fields not saved in AI Agent**
- AI Agent couldn't save relation fields (e.g., `companyId`) when
creating/upserting records
- Join column names weren't recognized during field validation
### Solutions
**Tool Error Handling:**
- Display error messages in UI with expandable error details
- Ensure tool parts always have valid `input` field (`input:
part.toolInput ?? {}`)
- Refactored `ToolStepRenderer` to accept complete `toolPart` object
**Relation Field Support:**
- Updated field validation in `create-record.service.ts` and
`upsert-record.service.ts`
- Check both `fieldIdByName` and `fieldIdByJoinColumnName` mappings
### Changes
- `packages/twenty-front/src/modules/ai/`
- `ToolStepRenderer.tsx` - Error state handling
- `AIChatAssistantMessageRenderer.tsx` - Pass complete toolPart
- `mapDBPartToUIMessagePart.ts` - Prevent null tool input
- `packages/twenty-server/src/engine/core-modules/record-crud/services/`
- `create-record.service.ts` - Add join column validation
- `upsert-record.service.ts` - Add join column validation
This commit is contained in:
@@ -82,15 +82,7 @@ export const AIChatAssistantMessageRenderer = ({
|
||||
default:
|
||||
{
|
||||
if (isToolUIPart(part)) {
|
||||
const { output, input, type } = part;
|
||||
return (
|
||||
<ToolStepRenderer
|
||||
key={index}
|
||||
input={input}
|
||||
output={output}
|
||||
toolName={type.split('-')[1]}
|
||||
/>
|
||||
);
|
||||
return <ToolStepRenderer key={index} toolPart={part} />;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -3,11 +3,10 @@ import styled from '@emotion/styled';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { IconChevronDown, IconChevronUp } from 'twenty-ui/display';
|
||||
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
|
||||
import { JsonTree } from 'twenty-ui/json-visualizer';
|
||||
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
|
||||
|
||||
import { ShimmeringText } from '@/ai/components/ShimmeringText';
|
||||
import { type ToolInput } from '@/ai/types/ToolInput';
|
||||
import { getToolIcon } from '@/ai/utils/getToolIcon';
|
||||
import { getToolDisplayMessage } from '@/ai/utils/getWebSearchToolDisplayMessage';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
@@ -96,26 +95,22 @@ const StyledJsonContainer = styled.div`
|
||||
|
||||
type TabType = 'output' | 'input';
|
||||
|
||||
export const ToolStepRenderer = ({
|
||||
input,
|
||||
output,
|
||||
toolName,
|
||||
}: {
|
||||
input: ToolInput;
|
||||
output: ToolUIPart['output'];
|
||||
toolName: string;
|
||||
}) => {
|
||||
export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
|
||||
const { t } = useLingui();
|
||||
const theme = useTheme();
|
||||
const { copyToClipboard } = useCopyToClipboard();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<TabType>('output');
|
||||
|
||||
const isExpandable = isDefined(output);
|
||||
const { input, output, type, errorText } = toolPart;
|
||||
const toolName = type.split('-')[1];
|
||||
|
||||
const hasError = isDefined(errorText);
|
||||
const isExpandable = isDefined(output) || hasError;
|
||||
|
||||
const isTwoFirstDepths = ({ depth }: { depth: number }) => depth < 2;
|
||||
|
||||
if (!output) {
|
||||
if (!output && !hasError) {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledLoadingContainer>
|
||||
@@ -129,11 +124,12 @@ export const ToolStepRenderer = ({
|
||||
);
|
||||
}
|
||||
|
||||
const displayMessage =
|
||||
output &&
|
||||
typeof output === 'object' &&
|
||||
'message' in output &&
|
||||
typeof output.message === 'string'
|
||||
const displayMessage = hasError
|
||||
? 'Tool execution failed'
|
||||
: output &&
|
||||
typeof output === 'object' &&
|
||||
'message' in output &&
|
||||
typeof output.message === 'string'
|
||||
? output.message
|
||||
: getToolDisplayMessage(input, toolName, true);
|
||||
|
||||
@@ -165,33 +161,41 @@ export const ToolStepRenderer = ({
|
||||
{isExpandable && (
|
||||
<AnimatedExpandableContainer isExpanded={isExpanded}>
|
||||
<StyledContentContainer>
|
||||
<StyledTabContainer>
|
||||
<StyledTab
|
||||
isActive={activeTab === 'output'}
|
||||
onClick={() => setActiveTab('output')}
|
||||
>
|
||||
Output
|
||||
</StyledTab>
|
||||
<StyledTab
|
||||
isActive={activeTab === 'input'}
|
||||
onClick={() => setActiveTab('input')}
|
||||
>
|
||||
Input
|
||||
</StyledTab>
|
||||
</StyledTabContainer>
|
||||
{hasError ? (
|
||||
<StyledJsonContainer>{errorText}</StyledJsonContainer>
|
||||
) : (
|
||||
<>
|
||||
<StyledTabContainer>
|
||||
<StyledTab
|
||||
isActive={activeTab === 'output'}
|
||||
onClick={() => setActiveTab('output')}
|
||||
>
|
||||
Output
|
||||
</StyledTab>
|
||||
<StyledTab
|
||||
isActive={activeTab === 'input'}
|
||||
onClick={() => setActiveTab('input')}
|
||||
>
|
||||
Input
|
||||
</StyledTab>
|
||||
</StyledTabContainer>
|
||||
|
||||
<StyledJsonContainer>
|
||||
<JsonTree
|
||||
value={(activeTab === 'output' ? result : input) as JsonValue}
|
||||
shouldExpandNodeInitially={isTwoFirstDepths}
|
||||
emptyArrayLabel={t`Empty Array`}
|
||||
emptyObjectLabel={t`Empty Object`}
|
||||
emptyStringLabel={t`[empty string]`}
|
||||
arrowButtonCollapsedLabel={t`Expand`}
|
||||
arrowButtonExpandedLabel={t`Collapse`}
|
||||
onNodeValueClick={copyToClipboard}
|
||||
/>
|
||||
</StyledJsonContainer>
|
||||
<StyledJsonContainer>
|
||||
<JsonTree
|
||||
value={
|
||||
(activeTab === 'output' ? result : input) as JsonValue
|
||||
}
|
||||
shouldExpandNodeInitially={isTwoFirstDepths}
|
||||
emptyArrayLabel={t`Empty Array`}
|
||||
emptyObjectLabel={t`Empty Object`}
|
||||
emptyStringLabel={t`[empty string]`}
|
||||
arrowButtonCollapsedLabel={t`Expand`}
|
||||
arrowButtonExpandedLabel={t`Collapse`}
|
||||
onNodeValueClick={copyToClipboard}
|
||||
/>
|
||||
</StyledJsonContainer>
|
||||
</>
|
||||
)}
|
||||
</StyledContentContainer>
|
||||
</AnimatedExpandableContainer>
|
||||
)}
|
||||
|
||||
@@ -59,7 +59,7 @@ export const mapDBPartToUIMessagePart = (
|
||||
return {
|
||||
type: part.type as `tool-${string}`,
|
||||
toolCallId: part.toolCallId!,
|
||||
input: part.toolInput,
|
||||
input: part.toolInput ?? {},
|
||||
output: part.toolOutput,
|
||||
errorText: part.errorMessage!,
|
||||
state: part.state,
|
||||
|
||||
+6
-2
@@ -73,8 +73,12 @@ export class CreateRecordService {
|
||||
});
|
||||
|
||||
const validObjectRecord = Object.fromEntries(
|
||||
Object.entries(objectRecord).filter(([key]) =>
|
||||
isDefined(objectMetadataItemWithFieldsMaps.fieldIdByName[key]),
|
||||
Object.entries(objectRecord).filter(
|
||||
([key]) =>
|
||||
isDefined(objectMetadataItemWithFieldsMaps.fieldIdByName[key]) ||
|
||||
isDefined(
|
||||
objectMetadataItemWithFieldsMaps.fieldIdByJoinColumnName[key],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
+6
-2
@@ -92,9 +92,13 @@ export class UpsertRecordService {
|
||||
});
|
||||
|
||||
const uniqueFieldsToUpdate = fieldsToUpdateArray
|
||||
.map((field) => objectMetadataItemWithFieldsMaps.fieldIdByName[field])
|
||||
.map(
|
||||
(field) =>
|
||||
objectMetadataItemWithFieldsMaps.fieldIdByName[field] ||
|
||||
objectMetadataItemWithFieldsMaps.fieldIdByJoinColumnName[field],
|
||||
)
|
||||
.map((fieldId) => objectMetadataItemWithFieldsMaps.fieldsById[fieldId])
|
||||
.filter((field) => field.isUnique || field.name === 'id');
|
||||
.filter((field) => field && (field.isUnique || field.name === 'id'));
|
||||
|
||||
const conflictPathsUniqueFieldsToUpdate = uniqueFieldsToUpdate.flatMap(
|
||||
(field) => {
|
||||
|
||||
Reference in New Issue
Block a user