Feat: Add reasoning summary and tool details display for AI responses (#14414)
Closes [#1405](https://github.com/twentyhq/core-team-issues/issues/1405) ### Implemented Features - Reasoning Summary: Collapsible display of AI thinking process with real-time streaming - Tool Execution: Detailed tool call rendering with expandable results and loading states - Error Handling: Clean error stream event display with proper formatting ### Demo https://github.com/user-attachments/assets/76ad29b7-d831-4bc8-a115-ad25f7ace6e2 <img width="504" height="819" alt="Screenshot 2025-09-12 at 3 23 39 AM" src="https://github.com/user-attachments/assets/82dc177d-f009-4e8a-8901-ee1a49201984" /> --------- Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
@@ -68,10 +68,10 @@ export type Agent = {
|
||||
|
||||
export type AgentChatMessage = {
|
||||
__typename?: 'AgentChatMessage';
|
||||
content: Scalars['String'];
|
||||
createdAt: Scalars['DateTime'];
|
||||
files: Array<File>;
|
||||
id: Scalars['UUID'];
|
||||
rawContent?: Maybe<Scalars['String']>;
|
||||
role: Scalars['String'];
|
||||
threadId: Scalars['UUID'];
|
||||
};
|
||||
@@ -4201,7 +4201,7 @@ export type GetAgentChatMessagesQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type GetAgentChatMessagesQuery = { __typename?: 'Query', agentChatMessages: Array<{ __typename?: 'AgentChatMessage', id: string, threadId: string, role: string, content: string, createdAt: string, files: Array<{ __typename?: 'File', id: string, name: string, fullPath: string, size: number, type: string, createdAt: string }> }> };
|
||||
export type GetAgentChatMessagesQuery = { __typename?: 'Query', agentChatMessages: Array<{ __typename?: 'AgentChatMessage', id: string, threadId: string, role: string, createdAt: string, rawContent?: string | null, files: Array<{ __typename?: 'File', id: string, name: string, fullPath: string, size: number, type: string, createdAt: string }> }> };
|
||||
|
||||
export type GetAgentChatThreadsQueryVariables = Exact<{
|
||||
agentId: Scalars['UUID'];
|
||||
@@ -6348,8 +6348,8 @@ export const GetAgentChatMessagesDocument = gql`
|
||||
id
|
||||
threadId
|
||||
role
|
||||
content
|
||||
createdAt
|
||||
rawContent
|
||||
files {
|
||||
id
|
||||
name
|
||||
|
||||
@@ -68,10 +68,10 @@ export type Agent = {
|
||||
|
||||
export type AgentChatMessage = {
|
||||
__typename?: 'AgentChatMessage';
|
||||
content: Scalars['String'];
|
||||
createdAt: Scalars['DateTime'];
|
||||
files: Array<File>;
|
||||
id: Scalars['UUID'];
|
||||
rawContent?: Maybe<Scalars['String']>;
|
||||
role: Scalars['String'];
|
||||
threadId: Scalars['UUID'];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { ErrorStepRenderer } from '@/ai/components/ErrorStepRenderer';
|
||||
import { ReasoningSummaryDisplay } from '@/ai/components/ReasoningSummaryDisplay';
|
||||
import { ToolStepRenderer } from '@/ai/components/ToolStepRenderer';
|
||||
import type { ParsedStep } from '@/ai/types/streamTypes';
|
||||
import { parseStream } from '@/ai/utils/parseStream';
|
||||
import { IconDotsVertical } from 'twenty-ui/display';
|
||||
|
||||
import { LazyMarkdownRenderer } from '@/ai/components/LazyMarkdownRenderer';
|
||||
import { agentStreamingMessageState } from '@/ai/states/agentStreamingMessageState';
|
||||
import { keyframes, useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
const StyledStepsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledDotsIconContainer = styled.div`
|
||||
align-items: center;
|
||||
border: ${({ theme }) => `1px solid ${theme.border.color.light}`};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-inline: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledDotsIcon = styled(IconDotsVertical)`
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
transform: rotate(90deg);
|
||||
`;
|
||||
|
||||
const dots = keyframes`
|
||||
0% { content: ''; }
|
||||
33% { content: '.'; }
|
||||
66% { content: '..'; }
|
||||
100% { content: '...'; }
|
||||
`;
|
||||
|
||||
const StyledToolCallContainer = styled.div`
|
||||
&::after {
|
||||
display: inline-block;
|
||||
content: '';
|
||||
animation: ${dots} 750ms steps(3, end) infinite;
|
||||
width: 2ch;
|
||||
text-align: left;
|
||||
}
|
||||
`;
|
||||
|
||||
const LoadingDotsIcon = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<StyledDotsIconContainer>
|
||||
<StyledDotsIcon size={theme.icon.size.xl} />
|
||||
</StyledDotsIconContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export const AIChatAssistantMessageRenderer = ({
|
||||
streamData,
|
||||
}: {
|
||||
streamData: string;
|
||||
}) => {
|
||||
const agentStreamingMessage = useRecoilValue(agentStreamingMessageState);
|
||||
const isStreaming =
|
||||
Boolean(agentStreamingMessage) && streamData === agentStreamingMessage;
|
||||
|
||||
if (!streamData) {
|
||||
return <LoadingDotsIcon />;
|
||||
}
|
||||
|
||||
const isPlainString =
|
||||
!streamData.includes('\n') ||
|
||||
!streamData.split('\n').some((line) => {
|
||||
try {
|
||||
JSON.parse(line);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (isPlainString) {
|
||||
return <LazyMarkdownRenderer text={streamData} />;
|
||||
}
|
||||
|
||||
const steps = parseStream(streamData);
|
||||
|
||||
if (!steps.length) {
|
||||
return <LoadingDotsIcon />;
|
||||
}
|
||||
|
||||
const renderStep = (step: ParsedStep, index: number) => {
|
||||
switch (step.type) {
|
||||
case 'tool':
|
||||
return <ToolStepRenderer key={index} events={step.events} />;
|
||||
case 'reasoning':
|
||||
return (
|
||||
<ReasoningSummaryDisplay
|
||||
key={index}
|
||||
content={step.content}
|
||||
isThinking={step.isThinking}
|
||||
/>
|
||||
);
|
||||
case 'text':
|
||||
return <LazyMarkdownRenderer key={index} text={step.content} />;
|
||||
case 'error':
|
||||
return (
|
||||
<ErrorStepRenderer
|
||||
key={index}
|
||||
message={step.message}
|
||||
error={step.error}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StyledStepsContainer>{steps.map(renderStep)}</StyledStepsContainer>
|
||||
{isStreaming && <StyledToolCallContainer />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +1,16 @@
|
||||
import { keyframes, useTheme } from '@emotion/react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Avatar, IconDotsVertical, IconSparkles } from 'twenty-ui/display';
|
||||
import { Avatar, IconSparkles } from 'twenty-ui/display';
|
||||
|
||||
import { LazyMarkdownRenderer } from '@/ai/components/LazyMarkdownRenderer';
|
||||
import { AgentChatFilePreview } from '@/ai/components/internal/AgentChatFilePreview';
|
||||
import { AgentChatMessageRole } from '@/ai/constants/AgentChatMessageRole';
|
||||
import { LightCopyIconButton } from '@/object-record/record-field/ui/components/LightCopyIconButton';
|
||||
|
||||
import { AIChatAssistantMessageRenderer } from '@/ai/components/AIChatAssistantMessageRenderer';
|
||||
import { type AgentChatMessage } from '~/generated/graphql';
|
||||
import { dateLocaleState } from '~/localization/states/dateLocaleState';
|
||||
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
|
||||
|
||||
const StyledMessageBubble = styled.div<{ isUser?: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -25,11 +24,10 @@ const StyledMessageBubble = styled.div<{ isUser?: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledMessageRow = styled.div<{ isShowingToolCall?: boolean }>`
|
||||
const StyledMessageRow = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: ${({ isShowingToolCall }) =>
|
||||
isShowingToolCall ? 'center' : 'flex-start'};
|
||||
align-items: flex-start;
|
||||
gap: ${({ theme }) => theme.spacing(3)};
|
||||
width: 100%;
|
||||
`;
|
||||
@@ -137,88 +135,22 @@ const StyledFilesContainer = styled.div`
|
||||
margin-top: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const dots = keyframes`
|
||||
0% { content: ''; }
|
||||
33% { content: '.'; }
|
||||
66% { content: '..'; }
|
||||
100% { content: '...'; }
|
||||
`;
|
||||
|
||||
const StyledToolCallContainer = styled.div`
|
||||
&::after {
|
||||
display: inline-block;
|
||||
content: '';
|
||||
animation: ${dots} 750ms steps(3, end) infinite;
|
||||
width: 2ch;
|
||||
text-align: left;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledDotsIconContainer = styled.div`
|
||||
align-items: center;
|
||||
border: ${({ theme }) => `1px solid ${theme.border.color.light}`};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-inline: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledDotsIcon = styled(IconDotsVertical)`
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
transform: rotate(90deg);
|
||||
`;
|
||||
|
||||
export const AIChatMessage = ({
|
||||
message,
|
||||
agentStreamingMessage,
|
||||
}: {
|
||||
message: AgentChatMessage;
|
||||
agentStreamingMessage: { streamingText: string; toolCall: string };
|
||||
agentStreamingMessage: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { localeCatalog } = useRecoilValue(dateLocaleState);
|
||||
|
||||
const markdownRender = (text: string) => {
|
||||
return <LazyMarkdownRenderer text={text} />;
|
||||
};
|
||||
|
||||
const getAssistantMessageContent = (message: AgentChatMessage) => {
|
||||
if (message.content !== '') {
|
||||
return markdownRender(message.content);
|
||||
}
|
||||
|
||||
if (agentStreamingMessage.streamingText !== '') {
|
||||
return markdownRender(agentStreamingMessage.streamingText);
|
||||
}
|
||||
|
||||
if (agentStreamingMessage.toolCall !== '') {
|
||||
return (
|
||||
<StyledToolCallContainer>
|
||||
{agentStreamingMessage.toolCall}
|
||||
</StyledToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledDotsIconContainer>
|
||||
<StyledDotsIcon size={theme.icon.size.xl} />
|
||||
</StyledDotsIconContainer>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledMessageBubble
|
||||
key={message.id}
|
||||
isUser={message.role === AgentChatMessageRole.USER}
|
||||
>
|
||||
<StyledMessageRow
|
||||
isShowingToolCall={
|
||||
message.role === AgentChatMessageRole.ASSISTANT &&
|
||||
message.content === '' &&
|
||||
agentStreamingMessage.streamingText === '' &&
|
||||
agentStreamingMessage.toolCall !== ''
|
||||
}
|
||||
>
|
||||
<StyledMessageRow>
|
||||
{message.role === AgentChatMessageRole.ASSISTANT && (
|
||||
<StyledAvatarContainer>
|
||||
<Avatar
|
||||
@@ -238,9 +170,13 @@ export const AIChatMessage = ({
|
||||
<StyledMessageText
|
||||
isUser={message.role === AgentChatMessageRole.USER}
|
||||
>
|
||||
{message.role === AgentChatMessageRole.ASSISTANT
|
||||
? getAssistantMessageContent(message)
|
||||
: message.content}
|
||||
{message.role === AgentChatMessageRole.ASSISTANT ? (
|
||||
<AIChatAssistantMessageRenderer
|
||||
streamData={message.rawContent || agentStreamingMessage}
|
||||
/>
|
||||
) : (
|
||||
message.rawContent
|
||||
)}
|
||||
</StyledMessageText>
|
||||
{message.files.length > 0 && (
|
||||
<StyledFilesContainer>
|
||||
@@ -249,7 +185,7 @@ export const AIChatMessage = ({
|
||||
))}
|
||||
</StyledFilesContainer>
|
||||
)}
|
||||
{message.content && (
|
||||
{message.rawContent && (
|
||||
<StyledMessageFooter className="message-footer">
|
||||
<span>
|
||||
{beautifyPastDateRelativeToNow(
|
||||
@@ -257,7 +193,7 @@ export const AIChatMessage = ({
|
||||
localeCatalog,
|
||||
)}
|
||||
</span>
|
||||
<LightCopyIconButton copyText={message.content} />
|
||||
<LightCopyIconButton copyText={message.rawContent} />
|
||||
</StyledMessageFooter>
|
||||
)}
|
||||
</StyledMessageContainer>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { extractErrorMessage } from '@/ai/utils/extractErrorMessage';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { IconAlertCircle } from 'twenty-ui/display';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: flex-start;
|
||||
background-color: ${({ theme }) => theme.color.red10};
|
||||
border: 1px solid ${({ theme }) => theme.color.red20};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
margin-block: ${({ theme }) => theme.spacing(2)};
|
||||
padding: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
const StyledIconContainer = styled.div`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.color.red60};
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const StyledContent = styled.div`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.div`
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
color: ${({ theme }) => theme.color.red80};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledMessage = styled.div`
|
||||
color: ${({ theme }) => theme.color.red70};
|
||||
line-height: ${({ theme }) => theme.text.lineHeight.lg};
|
||||
`;
|
||||
|
||||
export const ErrorStepRenderer = ({
|
||||
message,
|
||||
error,
|
||||
}: {
|
||||
message: string;
|
||||
error?: unknown;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const errorMessage = error ? extractErrorMessage(error) : message;
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledIconContainer>
|
||||
<IconAlertCircle size={theme.icon.size.md} />
|
||||
</StyledIconContainer>
|
||||
<StyledContent>
|
||||
<StyledTitle>Error</StyledTitle>
|
||||
<StyledMessage>{errorMessage}</StyledMessage>
|
||||
</StyledContent>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { IconBrain, IconChevronDown, IconChevronUp } from 'twenty-ui/display';
|
||||
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
|
||||
|
||||
import { ShimmeringText } from '@/ai/components/ShimmeringText';
|
||||
import { t } from '@lingui/core/macro';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledThinkingText = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
`;
|
||||
|
||||
const StyledReasoningContainer = styled.div`
|
||||
background: ${({ theme }) => theme.background.transparent.lighter};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
padding: ${({ theme }) => theme.spacing(3)};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
`;
|
||||
|
||||
const StyledReasoningText = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
line-height: ${({ theme }) => theme.text.lineHeight.lg};
|
||||
white-space: pre-wrap;
|
||||
`;
|
||||
|
||||
const StyledToggleButton = styled.div`
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
padding: ${({ theme }) => theme.spacing(1)} 0;
|
||||
transition: color ${({ theme }) => theme.animation.duration.normal}s;
|
||||
|
||||
&:hover {
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledIconContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
export const ReasoningSummaryDisplay = ({
|
||||
content,
|
||||
isThinking = false,
|
||||
}: {
|
||||
content: string;
|
||||
isThinking?: boolean;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const hasContent = content.trim().length > 0;
|
||||
|
||||
if (!hasContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
{isThinking && (
|
||||
<>
|
||||
<ShimmeringText>
|
||||
<StyledIconContainer>
|
||||
<IconBrain size={theme.icon.size.sm} />
|
||||
<StyledThinkingText>{t`Thinking...`}</StyledThinkingText>
|
||||
</StyledIconContainer>
|
||||
</ShimmeringText>
|
||||
<StyledReasoningContainer>
|
||||
<StyledReasoningText>{content}</StyledReasoningText>
|
||||
</StyledReasoningContainer>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasContent && !isThinking && (
|
||||
<>
|
||||
<StyledToggleButton onClick={() => setIsExpanded(!isExpanded)}>
|
||||
<StyledIconContainer>
|
||||
<IconBrain size={theme.icon.size.sm} />
|
||||
<span>{t`Finished thinking`}</span>
|
||||
</StyledIconContainer>
|
||||
{isExpanded ? (
|
||||
<IconChevronUp size={theme.icon.size.sm} />
|
||||
) : (
|
||||
<IconChevronDown size={theme.icon.size.sm} />
|
||||
)}
|
||||
</StyledToggleButton>
|
||||
|
||||
<AnimatedExpandableContainer isExpanded={isExpanded}>
|
||||
<StyledReasoningContainer>
|
||||
<StyledReasoningText>{content}</StyledReasoningText>
|
||||
</StyledReasoningContainer>
|
||||
</AnimatedExpandableContainer>
|
||||
</>
|
||||
)}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const StyledShimmeringText = styled.div`
|
||||
background: ${({ theme }) => theme.font.color.light}
|
||||
linear-gradient(
|
||||
90deg,
|
||||
${({ theme }) => theme.font.color.light} 0%,
|
||||
${({ theme }) => theme.font.color.primary} 50%,
|
||||
${({ theme }) => theme.font.color.light} 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
background-position: -200% top;
|
||||
background-repeat: no-repeat;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: shimmer-wave 1s infinite linear;
|
||||
|
||||
@keyframes shimmer-wave {
|
||||
0% {
|
||||
background-position: 200% top;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% top;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ShimmeringText = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<StyledShimmeringText className={className}>
|
||||
{children}
|
||||
</StyledShimmeringText>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { IconChevronDown, IconChevronUp } from 'twenty-ui/display';
|
||||
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
|
||||
|
||||
import { ShimmeringText } from '@/ai/components/ShimmeringText';
|
||||
import type {
|
||||
ToolCallEvent,
|
||||
ToolEvent,
|
||||
ToolResultEvent,
|
||||
} from '@/ai/types/streamTypes';
|
||||
import { extractErrorMessage } from '@/ai/utils/extractErrorMessage';
|
||||
import { getToolIcon } from '@/ai/utils/getToolIcon';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledLoadingContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledContentContainer = styled.div`
|
||||
background: ${({ theme }) => theme.background.transparent.lighter};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
padding: ${({ theme }) => theme.spacing(3)};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
`;
|
||||
|
||||
const StyledToggleButton = styled.div<{ isExpandable: boolean }>`
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: ${({ isExpandable }) => (isExpandable ? 'pointer' : 'auto')};
|
||||
display: flex;
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
padding: ${({ theme }) => theme.spacing(1)} 0;
|
||||
transition: color ${({ theme }) => theme.animation.duration.normal}s;
|
||||
|
||||
&:hover {
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledDisplayMessage = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
`;
|
||||
|
||||
const StyledPre = styled.pre`
|
||||
margin-top: ${({ theme }) => theme.spacing(1)};
|
||||
white-space: pre-wrap;
|
||||
`;
|
||||
|
||||
const StyledIconTextContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
|
||||
svg {
|
||||
min-width: ${({ theme }) => theme.icon.size.sm}px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ToolStepRenderer = ({ events }: { events: ToolEvent[] }) => {
|
||||
const theme = useTheme();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const toolCall = events[0] as ToolCallEvent | undefined;
|
||||
const toolResult = events.find(
|
||||
(event): event is ToolResultEvent => event.type === 'tool-result',
|
||||
);
|
||||
|
||||
if (!toolCall) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolOutput = toolResult?.result as ToolResultEvent['result'];
|
||||
const isStandardizedFormat =
|
||||
toolOutput && typeof toolOutput === 'object' && 'success' in toolOutput;
|
||||
|
||||
const hasResult = isStandardizedFormat
|
||||
? Boolean(toolOutput.result)
|
||||
: Boolean(toolResult?.result);
|
||||
const hasError = isStandardizedFormat ? Boolean(toolOutput.error) : false;
|
||||
const isExpandable = hasResult || hasError;
|
||||
|
||||
if (!toolResult) {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledLoadingContainer>
|
||||
<ShimmeringText>
|
||||
<StyledDisplayMessage>
|
||||
{toolCall.args.loadingMessage}
|
||||
</StyledDisplayMessage>
|
||||
</ShimmeringText>
|
||||
</StyledLoadingContainer>
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const displayMessage =
|
||||
toolResult?.result &&
|
||||
typeof toolResult.result === 'object' &&
|
||||
'message' in toolResult.result
|
||||
? (toolResult.result as { message: string }).message
|
||||
: undefined;
|
||||
|
||||
const ToolIcon = getToolIcon(toolCall.toolName);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledToggleButton
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
isExpandable={isExpandable}
|
||||
>
|
||||
<StyledIconTextContainer>
|
||||
<ToolIcon size={theme.icon.size.sm} />
|
||||
<StyledDisplayMessage>{displayMessage}</StyledDisplayMessage>
|
||||
</StyledIconTextContainer>
|
||||
{isExpandable &&
|
||||
(isExpanded ? (
|
||||
<IconChevronUp size={theme.icon.size.sm} />
|
||||
) : (
|
||||
<IconChevronDown size={theme.icon.size.sm} />
|
||||
))}
|
||||
</StyledToggleButton>
|
||||
|
||||
{isExpandable && (
|
||||
<AnimatedExpandableContainer isExpanded={isExpanded}>
|
||||
<StyledContentContainer>
|
||||
{isStandardizedFormat ? (
|
||||
<>
|
||||
{hasError && <div>{extractErrorMessage(toolOutput.error)}</div>}
|
||||
{hasResult && (
|
||||
<div>
|
||||
<StyledPre>
|
||||
{JSON.stringify(toolOutput.result, null, 2)}
|
||||
</StyledPre>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : toolResult?.result ? (
|
||||
JSON.stringify(toolResult.result, null, 2)
|
||||
) : undefined}
|
||||
</StyledContentContainer>
|
||||
</AnimatedExpandableContainer>
|
||||
)}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@@ -6,8 +6,8 @@ export const GET_AGENT_CHAT_MESSAGES = gql`
|
||||
id
|
||||
threadId
|
||||
role
|
||||
content
|
||||
createdAt
|
||||
rawContent
|
||||
files {
|
||||
id
|
||||
name
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
|
||||
import { useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
@@ -31,7 +30,6 @@ import { type AgentChatMessage } from '~/generated/graphql';
|
||||
import { agentChatInputState } from '../states/agentChatInputState';
|
||||
import { agentChatMessagesComponentState } from '../states/agentChatMessagesComponentState';
|
||||
import { agentStreamingMessageState } from '../states/agentStreamingMessageState';
|
||||
import { parseAgentStreamingChunk } from '../utils/parseAgentStreamingChunk';
|
||||
|
||||
type OptimisticMessage = AgentChatMessage & {
|
||||
isPending: boolean;
|
||||
@@ -39,7 +37,6 @@ type OptimisticMessage = AgentChatMessage & {
|
||||
|
||||
export const useAgentChat = (agentId: string, records?: ObjectRecord[]) => {
|
||||
const apolloClient = useApolloClient();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const { getObjectMetadataItemById } = useGetObjectMetadataItemById();
|
||||
|
||||
const contextStoreCurrentObjectMetadataItemId = useRecoilComponentValue(
|
||||
@@ -126,7 +123,7 @@ export const useAgentChat = (agentId: string, records?: ObjectRecord[]) => {
|
||||
id: v4(),
|
||||
threadId: currentThreadId as string,
|
||||
role: AgentChatMessageRole.USER,
|
||||
content,
|
||||
rawContent: content,
|
||||
createdAt: new Date().toISOString(),
|
||||
isPending: true,
|
||||
files: agentChatUploadedFiles,
|
||||
@@ -136,7 +133,7 @@ export const useAgentChat = (agentId: string, records?: ObjectRecord[]) => {
|
||||
id: v4(),
|
||||
threadId: currentThreadId as string,
|
||||
role: AgentChatMessageRole.ASSISTANT,
|
||||
content: '',
|
||||
rawContent: '',
|
||||
createdAt: new Date().toISOString(),
|
||||
isPending: true,
|
||||
files: [],
|
||||
@@ -180,27 +177,8 @@ export const useAgentChat = (agentId: string, records?: ObjectRecord[]) => {
|
||||
},
|
||||
context: {
|
||||
onChunk: (chunk: string) => {
|
||||
parseAgentStreamingChunk(chunk, {
|
||||
onTextDelta: (message: string) => {
|
||||
setAgentStreamingMessage((prev) => ({
|
||||
...prev,
|
||||
streamingText: prev.streamingText + message,
|
||||
}));
|
||||
scrollToBottom();
|
||||
},
|
||||
onToolCall: (message: string) => {
|
||||
setAgentStreamingMessage((prev) => ({
|
||||
...prev,
|
||||
toolCall: message,
|
||||
}));
|
||||
scrollToBottom();
|
||||
},
|
||||
onError: (message: string) => {
|
||||
enqueueErrorSnackBar({
|
||||
message,
|
||||
});
|
||||
},
|
||||
});
|
||||
setAgentStreamingMessage((prev) => prev + chunk);
|
||||
scrollToBottom();
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -225,10 +203,7 @@ export const useAgentChat = (agentId: string, records?: ObjectRecord[]) => {
|
||||
const { data } = await refetchMessages();
|
||||
|
||||
setAgentChatMessages(data?.agentChatMessages);
|
||||
setAgentStreamingMessage({
|
||||
toolCall: '',
|
||||
streamingText: '',
|
||||
});
|
||||
setAgentStreamingMessage('');
|
||||
scrollToBottom();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const agentStreamingMessageState = atom<{
|
||||
toolCall: string;
|
||||
streamingText: string;
|
||||
}>({
|
||||
export const agentStreamingMessageState = atom<string>({
|
||||
key: 'agentStreamingMessageState',
|
||||
default: {
|
||||
toolCall: '',
|
||||
streamingText: '',
|
||||
},
|
||||
default: '',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export type ToolCallEvent = {
|
||||
type: 'tool-call';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: {
|
||||
loadingMessage: string;
|
||||
input: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type ToolResultEvent = {
|
||||
type: 'tool-result';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
result: {
|
||||
success: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
message: string;
|
||||
};
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ToolEvent = ToolCallEvent | ToolResultEvent;
|
||||
|
||||
export type ErrorEvent = {
|
||||
type: 'error';
|
||||
message: string;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
export type ParsedStep =
|
||||
| { type: 'tool'; events: ToolEvent[] }
|
||||
| { type: 'reasoning'; content: string; isThinking: boolean }
|
||||
| { type: 'text'; content: string }
|
||||
| { type: 'error'; message: string; error?: unknown };
|
||||
-211
@@ -1,211 +0,0 @@
|
||||
import { parseAgentStreamingChunk } from '../parseAgentStreamingChunk';
|
||||
|
||||
describe('parseAgentStreamingChunk', () => {
|
||||
let mockCallbacks: {
|
||||
onTextDelta: jest.Mock;
|
||||
onToolCall: jest.Mock;
|
||||
onError: jest.Mock;
|
||||
onParseError: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockCallbacks = {
|
||||
onTextDelta: jest.fn(),
|
||||
onToolCall: jest.fn(),
|
||||
onError: jest.fn(),
|
||||
onParseError: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('valid event types', () => {
|
||||
it('should call onTextDelta for text-delta events', () => {
|
||||
const chunk = JSON.stringify({
|
||||
type: 'text-delta',
|
||||
message: 'Hello world',
|
||||
});
|
||||
|
||||
parseAgentStreamingChunk(chunk, mockCallbacks);
|
||||
|
||||
expect(mockCallbacks.onTextDelta).toHaveBeenCalledWith('Hello world');
|
||||
expect(mockCallbacks.onToolCall).not.toHaveBeenCalled();
|
||||
expect(mockCallbacks.onError).not.toHaveBeenCalled();
|
||||
expect(mockCallbacks.onParseError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onToolCall for tool-call events', () => {
|
||||
const chunk = JSON.stringify({
|
||||
type: 'tool-call',
|
||||
message: 'Tool execution result',
|
||||
});
|
||||
|
||||
parseAgentStreamingChunk(chunk, mockCallbacks);
|
||||
|
||||
expect(mockCallbacks.onToolCall).toHaveBeenCalledWith(
|
||||
'Tool execution result',
|
||||
);
|
||||
expect(mockCallbacks.onTextDelta).not.toHaveBeenCalled();
|
||||
expect(mockCallbacks.onError).not.toHaveBeenCalled();
|
||||
expect(mockCallbacks.onParseError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onError for error events', () => {
|
||||
const chunk = JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Something went wrong',
|
||||
});
|
||||
|
||||
parseAgentStreamingChunk(chunk, mockCallbacks);
|
||||
|
||||
expect(mockCallbacks.onError).toHaveBeenCalledWith(
|
||||
'Something went wrong',
|
||||
);
|
||||
expect(mockCallbacks.onTextDelta).not.toHaveBeenCalled();
|
||||
expect(mockCallbacks.onToolCall).not.toHaveBeenCalled();
|
||||
expect(mockCallbacks.onParseError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple events in chunk', () => {
|
||||
it('should process multiple events separated by newlines', () => {
|
||||
const chunk = [
|
||||
JSON.stringify({ type: 'text-delta', message: 'First message' }),
|
||||
JSON.stringify({ type: 'tool-call', message: 'Tool result' }),
|
||||
JSON.stringify({ type: 'error', message: 'Error occurred' }),
|
||||
].join('\n');
|
||||
|
||||
parseAgentStreamingChunk(chunk, mockCallbacks);
|
||||
|
||||
expect(mockCallbacks.onTextDelta).toHaveBeenCalledWith('First message');
|
||||
expect(mockCallbacks.onToolCall).toHaveBeenCalledWith('Tool result');
|
||||
expect(mockCallbacks.onError).toHaveBeenCalledWith('Error occurred');
|
||||
expect(mockCallbacks.onParseError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip empty lines', () => {
|
||||
const chunk = [
|
||||
JSON.stringify({ type: 'text-delta', message: 'First message' }),
|
||||
'',
|
||||
' ',
|
||||
JSON.stringify({ type: 'tool-call', message: 'Tool result' }),
|
||||
].join('\n');
|
||||
|
||||
parseAgentStreamingChunk(chunk, mockCallbacks);
|
||||
|
||||
expect(mockCallbacks.onTextDelta).toHaveBeenCalledWith('First message');
|
||||
expect(mockCallbacks.onToolCall).toHaveBeenCalledWith('Tool result');
|
||||
expect(mockCallbacks.onParseError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSON parsing errors', () => {
|
||||
it('should call onParseError for invalid JSON', () => {
|
||||
const chunk = 'invalid json content';
|
||||
|
||||
parseAgentStreamingChunk(chunk, mockCallbacks);
|
||||
|
||||
expect(mockCallbacks.onParseError).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
'invalid json content',
|
||||
);
|
||||
expect(mockCallbacks.onTextDelta).not.toHaveBeenCalled();
|
||||
expect(mockCallbacks.onToolCall).not.toHaveBeenCalled();
|
||||
expect(mockCallbacks.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onParseError for malformed JSON', () => {
|
||||
const chunk = '{"type": "text-delta", "message": "unclosed quote}';
|
||||
|
||||
parseAgentStreamingChunk(chunk, mockCallbacks);
|
||||
|
||||
expect(mockCallbacks.onParseError).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
'{"type": "text-delta", "message": "unclosed quote}',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle mixed valid and invalid JSON in same chunk', () => {
|
||||
const chunk = [
|
||||
JSON.stringify({ type: 'text-delta', message: 'Valid message' }),
|
||||
'invalid json',
|
||||
JSON.stringify({ type: 'tool-call', message: 'Another valid message' }),
|
||||
].join('\n');
|
||||
|
||||
parseAgentStreamingChunk(chunk, mockCallbacks);
|
||||
|
||||
expect(mockCallbacks.onTextDelta).toHaveBeenCalledWith('Valid message');
|
||||
expect(mockCallbacks.onToolCall).toHaveBeenCalledWith(
|
||||
'Another valid message',
|
||||
);
|
||||
expect(mockCallbacks.onParseError).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
'invalid json',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('optional callbacks', () => {
|
||||
it('should not throw when callbacks are undefined', () => {
|
||||
const chunk = JSON.stringify({
|
||||
type: 'text-delta',
|
||||
message: 'Test message',
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
parseAgentStreamingChunk(chunk, {});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle partial callback definitions', () => {
|
||||
const chunk = JSON.stringify({
|
||||
type: 'text-delta',
|
||||
message: 'Test message',
|
||||
});
|
||||
|
||||
const partialCallbacks = {
|
||||
onTextDelta: jest.fn(),
|
||||
};
|
||||
|
||||
parseAgentStreamingChunk(chunk, partialCallbacks);
|
||||
|
||||
expect(partialCallbacks.onTextDelta).toHaveBeenCalledWith('Test message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty chunk', () => {
|
||||
parseAgentStreamingChunk('', mockCallbacks);
|
||||
|
||||
expect(mockCallbacks.onTextDelta).not.toHaveBeenCalled();
|
||||
expect(mockCallbacks.onToolCall).not.toHaveBeenCalled();
|
||||
expect(mockCallbacks.onError).not.toHaveBeenCalled();
|
||||
expect(mockCallbacks.onParseError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle chunk with only whitespace', () => {
|
||||
parseAgentStreamingChunk(' \n\t\n ', mockCallbacks);
|
||||
|
||||
expect(mockCallbacks.onTextDelta).not.toHaveBeenCalled();
|
||||
expect(mockCallbacks.onToolCall).not.toHaveBeenCalled();
|
||||
expect(mockCallbacks.onError).not.toHaveBeenCalled();
|
||||
expect(mockCallbacks.onParseError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle unknown event types gracefully', () => {
|
||||
const chunk = JSON.stringify({
|
||||
type: 'unknown-type',
|
||||
message: 'Unknown event',
|
||||
});
|
||||
|
||||
parseAgentStreamingChunk(chunk, mockCallbacks);
|
||||
|
||||
expect(mockCallbacks.onTextDelta).not.toHaveBeenCalled();
|
||||
expect(mockCallbacks.onToolCall).not.toHaveBeenCalled();
|
||||
expect(mockCallbacks.onError).not.toHaveBeenCalled();
|
||||
expect(mockCallbacks.onParseError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,431 @@
|
||||
import { parseStream } from '../parseStream';
|
||||
|
||||
describe('parseStream', () => {
|
||||
describe('tool-call events', () => {
|
||||
it('should parse tool-call event correctly', () => {
|
||||
const streamText = JSON.stringify({
|
||||
type: 'tool-call',
|
||||
toolCallId: 'call-123',
|
||||
toolName: 'send_email',
|
||||
args: {
|
||||
loadingMessage: 'Sending email...',
|
||||
input: { to: 'test@example.com' },
|
||||
},
|
||||
});
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'tool',
|
||||
events: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'call-123',
|
||||
toolName: 'send_email',
|
||||
args: {
|
||||
loadingMessage: 'Sending email...',
|
||||
input: { to: 'test@example.com' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool-result events', () => {
|
||||
it('should parse tool-result event and append to existing tool entry', () => {
|
||||
const streamText = [
|
||||
JSON.stringify({
|
||||
type: 'tool-call',
|
||||
toolCallId: 'call-123',
|
||||
toolName: 'send_email',
|
||||
args: { loadingMessage: 'Sending email...', input: {} },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-123',
|
||||
toolName: 'send_email',
|
||||
result: { sucess: true, result: 'Email sent', message: 'Success' },
|
||||
message: 'Email sent successfully',
|
||||
}),
|
||||
].join('\n');
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'tool',
|
||||
events: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'call-123',
|
||||
toolName: 'send_email',
|
||||
args: { loadingMessage: 'Sending email...', input: {} },
|
||||
},
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-123',
|
||||
toolName: 'send_email',
|
||||
result: { sucess: true, result: 'Email sent', message: 'Success' },
|
||||
message: 'Email sent successfully',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should create new tool entry for orphaned tool-result', () => {
|
||||
const streamText = JSON.stringify({
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-456',
|
||||
toolName: 'http_request',
|
||||
result: {
|
||||
sucess: true,
|
||||
result: 'Response received',
|
||||
message: 'Success',
|
||||
},
|
||||
message: 'Request completed',
|
||||
});
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'tool',
|
||||
events: [
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-456',
|
||||
toolName: 'http_request',
|
||||
result: {
|
||||
sucess: true,
|
||||
result: 'Response received',
|
||||
message: 'Success',
|
||||
},
|
||||
message: 'Request completed',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('reasoning events', () => {
|
||||
it('should parse reasoning events correctly', () => {
|
||||
const streamText = [
|
||||
JSON.stringify({
|
||||
type: 'reasoning',
|
||||
textDelta: 'Let me think about this...',
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'reasoning',
|
||||
textDelta: ' I need to consider the options.',
|
||||
}),
|
||||
JSON.stringify({ type: 'reasoning-signature' }),
|
||||
].join('\n');
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'reasoning',
|
||||
content: 'Let me think about this... I need to consider the options.',
|
||||
isThinking: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle reasoning without signature as thinking', () => {
|
||||
const streamText = JSON.stringify({
|
||||
type: 'reasoning',
|
||||
textDelta: 'Still thinking...',
|
||||
});
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'reasoning',
|
||||
content: 'Still thinking...',
|
||||
isThinking: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should concatenate multiple reasoning deltas', () => {
|
||||
const streamText = [
|
||||
JSON.stringify({ type: 'reasoning', textDelta: 'First part' }),
|
||||
JSON.stringify({ type: 'reasoning', textDelta: ' second part' }),
|
||||
JSON.stringify({ type: 'reasoning', textDelta: ' third part' }),
|
||||
].join('\n');
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'reasoning',
|
||||
content: 'First part second part third part',
|
||||
isThinking: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('text-delta events', () => {
|
||||
it('should parse text-delta events correctly', () => {
|
||||
const streamText = [
|
||||
JSON.stringify({ type: 'text-delta', textDelta: 'Hello, ' }),
|
||||
JSON.stringify({ type: 'text-delta', textDelta: 'world!' }),
|
||||
].join('\n');
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'text',
|
||||
content: 'Hello, world!',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty textDelta', () => {
|
||||
const streamText = JSON.stringify({ type: 'text-delta', textDelta: '' });
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'text',
|
||||
content: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error events', () => {
|
||||
it('should parse error events correctly', () => {
|
||||
const streamText = JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Something went wrong',
|
||||
error: { code: 'TIMEOUT', details: 'Request timed out' },
|
||||
});
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'error',
|
||||
message: 'Something went wrong',
|
||||
error: { code: 'TIMEOUT', details: 'Request timed out' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle error without error details', () => {
|
||||
const streamText = JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Generic error',
|
||||
});
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'error',
|
||||
message: 'Generic error',
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default message when none provided', () => {
|
||||
const streamText = JSON.stringify({
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'error',
|
||||
message: 'An error occurred',
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('step-finish events', () => {
|
||||
it('should flush current text block on step-finish', () => {
|
||||
const streamText = [
|
||||
JSON.stringify({ type: 'text-delta', textDelta: 'Some text' }),
|
||||
JSON.stringify({ type: 'step-finish' }),
|
||||
].join('\n');
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'text',
|
||||
content: 'Some text',
|
||||
});
|
||||
});
|
||||
|
||||
it('should mark reasoning as not thinking on step-finish', () => {
|
||||
const streamText = [
|
||||
JSON.stringify({ type: 'reasoning', textDelta: 'Thinking...' }),
|
||||
JSON.stringify({ type: 'step-finish' }),
|
||||
].join('\n');
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'reasoning',
|
||||
content: 'Thinking...',
|
||||
isThinking: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed events', () => {
|
||||
it('should handle mixed event types correctly', () => {
|
||||
const streamText = [
|
||||
JSON.stringify({ type: 'text-delta', textDelta: 'Starting...' }),
|
||||
JSON.stringify({
|
||||
type: 'tool-call',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'send_email',
|
||||
args: { loadingMessage: 'Sending...', input: {} },
|
||||
}),
|
||||
JSON.stringify({ type: 'reasoning', textDelta: 'Let me think...' }),
|
||||
JSON.stringify({
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'send_email',
|
||||
result: { sucess: true, message: 'Done' },
|
||||
message: 'Email sent',
|
||||
}),
|
||||
JSON.stringify({ type: 'text-delta', textDelta: 'Finished!' }),
|
||||
].join('\n');
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result[0]).toEqual({ type: 'text', content: 'Starting...' });
|
||||
expect(result[1]).toEqual({
|
||||
type: 'tool',
|
||||
events: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'send_email',
|
||||
args: { loadingMessage: 'Sending...', input: {} },
|
||||
},
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'send_email',
|
||||
result: { sucess: true, message: 'Done' },
|
||||
message: 'Email sent',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result[2]).toEqual({
|
||||
type: 'reasoning',
|
||||
content: 'Let me think...',
|
||||
isThinking: true,
|
||||
});
|
||||
expect(result[3]).toEqual({
|
||||
type: 'text',
|
||||
content: 'Finished!',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty stream', () => {
|
||||
const result = parseStream('');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle whitespace-only stream', () => {
|
||||
const result = parseStream(' \n \t ');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should skip invalid JSON lines', () => {
|
||||
const streamText = [
|
||||
'invalid json line',
|
||||
JSON.stringify({ type: 'text-delta', textDelta: 'Valid content' }),
|
||||
'another invalid line',
|
||||
].join('\n');
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'text',
|
||||
content: 'Valid content',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle unknown event types', () => {
|
||||
const streamText = JSON.stringify({
|
||||
type: 'unknown-event',
|
||||
data: 'some data',
|
||||
});
|
||||
|
||||
const result = parseStream(streamText);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should flush remaining text block at end', () => {
|
||||
const streamText = JSON.stringify({
|
||||
type: 'text-delta',
|
||||
textDelta: 'Unflushed content',
|
||||
});
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'text',
|
||||
content: 'Unflushed content',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('text block transitions', () => {
|
||||
it('should create new text block when switching from reasoning to text', () => {
|
||||
const streamText = [
|
||||
JSON.stringify({ type: 'reasoning', textDelta: 'Thinking...' }),
|
||||
JSON.stringify({ type: 'text-delta', textDelta: 'Speaking...' }),
|
||||
].join('\n');
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'reasoning',
|
||||
content: 'Thinking...',
|
||||
isThinking: true,
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
type: 'text',
|
||||
content: 'Speaking...',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create new reasoning block when switching from text to reasoning', () => {
|
||||
const streamText = [
|
||||
JSON.stringify({ type: 'text-delta', textDelta: 'Speaking...' }),
|
||||
JSON.stringify({ type: 'reasoning', textDelta: 'Thinking...' }),
|
||||
].join('\n');
|
||||
|
||||
const result = parseStream(streamText);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'text',
|
||||
content: 'Speaking...',
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
type: 'reasoning',
|
||||
content: 'Thinking...',
|
||||
isThinking: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const extractErrorMessage = (error: unknown): string => {
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (!isDefined(error) || typeof error !== 'object') {
|
||||
return 'An unexpected error occurred';
|
||||
}
|
||||
|
||||
if ('message' in error && typeof error.message === 'string') {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (
|
||||
'error' in error &&
|
||||
isDefined(error.error) &&
|
||||
typeof error.error === 'object' &&
|
||||
'message' in error.error &&
|
||||
typeof error.error.message === 'string'
|
||||
) {
|
||||
return error.error.message;
|
||||
}
|
||||
|
||||
if (
|
||||
'data' in error &&
|
||||
isDefined(error.data) &&
|
||||
typeof error.data === 'object' &&
|
||||
'error' in error.data &&
|
||||
isDefined(error.data.error) &&
|
||||
typeof error.data.error === 'object' &&
|
||||
'message' in error.data.error &&
|
||||
typeof error.data.error.message === 'string'
|
||||
) {
|
||||
return error.data.error.message;
|
||||
}
|
||||
|
||||
return 'An unexpected error occurred';
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { IconDatabase, IconMail, IconTool, IconWorld } from 'twenty-ui/display';
|
||||
|
||||
const TOOL_ICON_MAPPINGS = [
|
||||
{
|
||||
keywords: ['email'],
|
||||
icon: IconMail,
|
||||
},
|
||||
{
|
||||
keywords: ['http_request'],
|
||||
icon: IconWorld,
|
||||
},
|
||||
{
|
||||
keywords: ['create_', 'update_', 'find_', 'delete_'],
|
||||
icon: IconDatabase,
|
||||
},
|
||||
{
|
||||
keywords: ['workflow', 'handoff'],
|
||||
icon: IconTool,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const getToolIcon = (toolName: string) => {
|
||||
const mapping = TOOL_ICON_MAPPINGS.find(({ keywords }) =>
|
||||
keywords.some((keyword) => toolName.includes(keyword)),
|
||||
);
|
||||
|
||||
return mapping?.icon ?? IconTool;
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
export type AgentStreamingEvent = {
|
||||
type: 'text-delta' | 'tool-call' | 'error';
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type AgentStreamingParserCallbacks = {
|
||||
onTextDelta?: (message: string) => void;
|
||||
onToolCall?: (message: string) => void;
|
||||
onError?: (message: string) => void;
|
||||
onParseError?: (error: Error, rawLine: string) => void;
|
||||
};
|
||||
|
||||
export const parseAgentStreamingChunk = (
|
||||
chunk: string,
|
||||
callbacks: AgentStreamingParserCallbacks,
|
||||
): void => {
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() !== '') {
|
||||
try {
|
||||
const event = JSON.parse(line) as AgentStreamingEvent;
|
||||
|
||||
switch (event.type) {
|
||||
case 'text-delta':
|
||||
callbacks.onTextDelta?.(event.message);
|
||||
break;
|
||||
case 'tool-call':
|
||||
callbacks.onToolCall?.(event.message);
|
||||
break;
|
||||
case 'error':
|
||||
callbacks.onError?.(event.message);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to parse stream event:', error);
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(`Unknown parsing error: ${String(error)}`);
|
||||
callbacks.onParseError?.(errorMessage, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
type ParsedStep,
|
||||
type ToolEvent,
|
||||
type ToolResultEvent,
|
||||
} from '@/ai/types/streamTypes';
|
||||
|
||||
type TextBlock =
|
||||
| { type: 'reasoning'; content: string; isThinking: boolean }
|
||||
| { type: 'text'; content: string }
|
||||
| null;
|
||||
|
||||
export const parseStream = (streamText: string): ParsedStep[] => {
|
||||
const lines = streamText.trim().split('\n');
|
||||
|
||||
const output: ParsedStep[] = [];
|
||||
let currentTextBlock: TextBlock = null;
|
||||
|
||||
const flushTextBlock = () => {
|
||||
if (isDefined(currentTextBlock)) {
|
||||
output.push(currentTextBlock);
|
||||
currentTextBlock = null;
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'tool-call':
|
||||
flushTextBlock();
|
||||
output.push({
|
||||
type: 'tool',
|
||||
events: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
args: event.args,
|
||||
},
|
||||
] as ToolEvent[],
|
||||
});
|
||||
break;
|
||||
|
||||
case 'tool-result': {
|
||||
flushTextBlock();
|
||||
|
||||
const toolEntry = output.find(
|
||||
(item): item is { type: 'tool'; events: ToolEvent[] } =>
|
||||
item.type === 'tool' &&
|
||||
item.events.some(
|
||||
(e) =>
|
||||
e.type === 'tool-call' && e.toolCallId === event.toolCallId,
|
||||
),
|
||||
);
|
||||
|
||||
const resultEvent: ToolResultEvent = {
|
||||
type: 'tool-result',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
result: event.result,
|
||||
message: event.message,
|
||||
};
|
||||
|
||||
if (isDefined(toolEntry)) {
|
||||
toolEntry.events.push(resultEvent);
|
||||
} else {
|
||||
output.push({
|
||||
type: 'tool',
|
||||
events: [resultEvent],
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'reasoning':
|
||||
if (!currentTextBlock || currentTextBlock.type !== 'reasoning') {
|
||||
flushTextBlock();
|
||||
currentTextBlock = {
|
||||
type: 'reasoning',
|
||||
content: '',
|
||||
isThinking: true,
|
||||
};
|
||||
}
|
||||
currentTextBlock.content += event.textDelta || '';
|
||||
break;
|
||||
|
||||
case 'text-delta':
|
||||
if (!currentTextBlock || currentTextBlock.type !== 'text') {
|
||||
flushTextBlock();
|
||||
currentTextBlock = { type: 'text', content: '' };
|
||||
}
|
||||
currentTextBlock.content += event.textDelta || '';
|
||||
break;
|
||||
|
||||
case 'reasoning-signature':
|
||||
if (currentTextBlock?.type === 'reasoning') {
|
||||
currentTextBlock.isThinking = false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'step-finish':
|
||||
if (currentTextBlock?.type === 'reasoning') {
|
||||
currentTextBlock.isThinking = false;
|
||||
}
|
||||
flushTextBlock();
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
flushTextBlock();
|
||||
output.push({
|
||||
type: 'error',
|
||||
message: event.message || 'An error occurred',
|
||||
error: event.error,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
flushTextBlock();
|
||||
return output;
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { type MigrationInterface, type QueryRunner } from 'typeorm';
|
||||
|
||||
export class RemoveContentFromAgentChatMessage1757991657472
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'RemoveContentFromAgentChatMessage1757991657472';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."agentChatMessage" RENAME COLUMN "content" TO "rawContent"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."agentChatMessage" ALTER COLUMN "rawContent" DROP NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."agentChatMessage" ALTER COLUMN "rawContent" SET NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."agentChatMessage" RENAME COLUMN "rawContent" TO "content"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export interface AIModelConfig {
|
||||
provider: ModelProvider;
|
||||
inputCostPer1kTokensInCents: number;
|
||||
outputCostPer1kTokensInCents: number;
|
||||
doesSupportThinking?: boolean;
|
||||
}
|
||||
|
||||
export const AI_MODELS: AIModelConfig[] = [
|
||||
@@ -55,6 +56,7 @@ export const AI_MODELS: AIModelConfig[] = [
|
||||
provider: ModelProvider.ANTHROPIC,
|
||||
inputCostPer1kTokensInCents: 1.5,
|
||||
outputCostPer1kTokensInCents: 7.5,
|
||||
doesSupportThinking: true,
|
||||
},
|
||||
{
|
||||
modelId: 'claude-sonnet-4-20250514',
|
||||
@@ -62,6 +64,7 @@ export const AI_MODELS: AIModelConfig[] = [
|
||||
provider: ModelProvider.ANTHROPIC,
|
||||
inputCostPer1kTokensInCents: 0.3,
|
||||
outputCostPer1kTokensInCents: 1.5,
|
||||
doesSupportThinking: true,
|
||||
},
|
||||
{
|
||||
modelId: 'claude-3-5-haiku-20241022',
|
||||
@@ -69,6 +72,7 @@ export const AI_MODELS: AIModelConfig[] = [
|
||||
provider: ModelProvider.ANTHROPIC,
|
||||
inputCostPer1kTokensInCents: 0.08,
|
||||
outputCostPer1kTokensInCents: 0.4,
|
||||
doesSupportThinking: true,
|
||||
},
|
||||
{
|
||||
modelId: 'grok-3',
|
||||
|
||||
+12
-4
@@ -1,12 +1,12 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-adapter.service';
|
||||
import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
|
||||
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
|
||||
import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service';
|
||||
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
|
||||
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
|
||||
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
|
||||
const createMockToolRegistry = () => ({
|
||||
getAllToolTypes: jest.fn(),
|
||||
@@ -27,6 +27,8 @@ describe('ToolAdapterService', () => {
|
||||
|
||||
// Shared tools
|
||||
const unflaggedToolExecute = jest.fn(async (input: ToolInput) => ({
|
||||
success: true,
|
||||
message: 'Tool executed successfully',
|
||||
result: { echoed: input },
|
||||
}));
|
||||
const unflaggedTool: Tool = {
|
||||
@@ -36,6 +38,8 @@ describe('ToolAdapterService', () => {
|
||||
};
|
||||
|
||||
const flaggedToolExecute = jest.fn(async (input: ToolInput) => ({
|
||||
success: true,
|
||||
message: 'Tool executed successfully',
|
||||
result: { sent: input },
|
||||
}));
|
||||
const flaggedTool: Tool = {
|
||||
@@ -152,6 +156,10 @@ describe('ToolAdapterService', () => {
|
||||
|
||||
// Ensure wrapper forwards only parameters.input
|
||||
expect(unflaggedToolExecute).toHaveBeenCalledWith(input);
|
||||
expect(result).toEqual({ result: { echoed: input } });
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
message: 'Tool executed successfully',
|
||||
result: { echoed: input },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
|
||||
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
import { getMockObjectMetadataEntity } from 'src/utils/__test__/get-object-metadata-entity.mock';
|
||||
|
||||
@@ -147,7 +147,7 @@ describe('ToolService', () => {
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.record).toEqual(record);
|
||||
expect(result.result).toEqual(record);
|
||||
expect(ormManager.getRepositoryForWorkspace).toHaveBeenCalledWith(
|
||||
workspaceId,
|
||||
'testObject',
|
||||
@@ -189,8 +189,8 @@ describe('ToolService', () => {
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.records).toEqual(records);
|
||||
expect(result.count).toBe(2);
|
||||
expect(result.result.records).toEqual(records);
|
||||
expect(result.result.count).toBe(2);
|
||||
expect(mockRepo.find).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+2
@@ -16,6 +16,7 @@ export interface RegisteredAIModel {
|
||||
modelId: string;
|
||||
provider: ModelProvider;
|
||||
model: LanguageModel;
|
||||
doesSupportThinking?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -86,6 +87,7 @@ export class AiModelRegistryService {
|
||||
modelId: modelConfig.modelId,
|
||||
provider: ModelProvider.ANTHROPIC,
|
||||
model: anthropic(modelConfig.modelId),
|
||||
doesSupportThinking: modelConfig.doesSupportThinking,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -173,15 +173,17 @@ export class ToolService {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
records,
|
||||
count: records.length,
|
||||
message: `Found ${records.length} ${objectName} records`,
|
||||
result: {
|
||||
records,
|
||||
count: records.length,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to find ${objectName} records`,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -205,8 +207,8 @@ export class ToolService {
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required',
|
||||
message: `Failed to find ${objectName}: Record ID is required`,
|
||||
error: 'Record ID is required',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -217,21 +219,21 @@ export class ToolService {
|
||||
if (!record) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to find ${objectName}: Record with ID ${id} not found`,
|
||||
error: 'Record not found',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
record,
|
||||
message: `Found ${objectName} record`,
|
||||
result: record,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to find ${objectName} record`,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -261,8 +263,8 @@ export class ToolService {
|
||||
if (!objectMetadataItemWithFieldsMaps) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Object metadata not found',
|
||||
message: `Failed to create ${objectName}: Object metadata not found`,
|
||||
error: 'Object metadata not found',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -276,14 +278,14 @@ export class ToolService {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
record: createdRecord,
|
||||
message: `Successfully created ${objectName}`,
|
||||
result: createdRecord,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to create ${objectName}`,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -307,8 +309,8 @@ export class ToolService {
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required for update',
|
||||
message: `Failed to update ${objectName}: Record ID is required`,
|
||||
error: 'Record ID is required for update',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -319,8 +321,8 @@ export class ToolService {
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to update ${objectName}: Record with ID ${id} not found`,
|
||||
error: 'Record not found',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -335,8 +337,8 @@ export class ToolService {
|
||||
if (!objectMetadataItemWithFieldsMaps) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Object metadata not found',
|
||||
message: `Failed to update ${objectName}: Object metadata not found`,
|
||||
error: 'Object metadata not found',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -355,21 +357,21 @@ export class ToolService {
|
||||
if (!updatedRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to retrieve updated record',
|
||||
message: `Failed to update ${objectName}: Could not retrieve updated record`,
|
||||
error: 'Failed to retrieve updated record',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
record: updatedRecord,
|
||||
message: `Successfully updated ${objectName}`,
|
||||
result: updatedRecord,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to update ${objectName}`,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -393,8 +395,8 @@ export class ToolService {
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required for soft delete',
|
||||
message: `Failed to soft delete ${objectName}: Record ID is required`,
|
||||
error: 'Record ID is required for soft delete',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -405,8 +407,8 @@ export class ToolService {
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to soft delete ${objectName}: Record with ID ${id} not found`,
|
||||
error: 'Record not found',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -415,12 +417,13 @@ export class ToolService {
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully soft deleted ${objectName}`,
|
||||
result: { id },
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to soft delete ${objectName}`,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -444,8 +447,8 @@ export class ToolService {
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required for destroy',
|
||||
message: `Failed to destroy ${objectName}: Record ID is required`,
|
||||
error: 'Record ID is required for destroy',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -456,8 +459,8 @@ export class ToolService {
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to destroy ${objectName}: Record with ID ${id} not found`,
|
||||
error: 'Record not found',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -466,12 +469,13 @@ export class ToolService {
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully destroyed ${objectName}`,
|
||||
result: { id },
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to destroy ${objectName}`,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -495,8 +499,8 @@ export class ToolService {
|
||||
if (!filter || typeof filter !== 'object' || !('id' in filter)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Filter with record IDs is required for bulk soft delete',
|
||||
message: `Failed to soft delete many ${objectName}: Filter with record IDs is required`,
|
||||
error: 'Filter with record IDs is required for bulk soft delete',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -506,8 +510,8 @@ export class ToolService {
|
||||
if (!Array.isArray(recordIds) || recordIds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'At least one record ID is required for bulk soft delete',
|
||||
message: `Failed to soft delete many ${objectName}: At least one record ID is required`,
|
||||
error: 'At least one record ID is required for bulk soft delete',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -518,8 +522,8 @@ export class ToolService {
|
||||
if (existingRecords.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No records found to soft delete',
|
||||
message: `Failed to soft delete many ${objectName}: No records found with the provided IDs`,
|
||||
error: 'No records found to soft delete',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -527,14 +531,17 @@ export class ToolService {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: existingRecords.length,
|
||||
message: `Successfully soft deleted ${existingRecords.length} ${objectName} records`,
|
||||
result: {
|
||||
count: existingRecords.length,
|
||||
deletedIds: recordIds,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to soft delete many ${objectName}`,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -558,8 +565,8 @@ export class ToolService {
|
||||
if (!filter || typeof filter !== 'object' || !('id' in filter)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Filter with record IDs is required for bulk destroy',
|
||||
message: `Failed to destroy many ${objectName}: Filter with record IDs is required`,
|
||||
error: 'Filter with record IDs is required for bulk destroy',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -569,8 +576,8 @@ export class ToolService {
|
||||
if (!Array.isArray(recordIds) || recordIds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'At least one record ID is required for bulk destroy',
|
||||
message: `Failed to destroy many ${objectName}: At least one record ID is required`,
|
||||
error: 'At least one record ID is required for bulk destroy',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -581,8 +588,8 @@ export class ToolService {
|
||||
if (existingRecords.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No records found to destroy',
|
||||
message: `Failed to destroy many ${objectName}: No records found with the provided IDs`,
|
||||
error: 'No records found to destroy',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -590,14 +597,17 @@ export class ToolService {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: existingRecords.length,
|
||||
message: `Successfully destroyed ${existingRecords.length} ${objectName} records`,
|
||||
result: {
|
||||
count: existingRecords.length,
|
||||
destroyedIds: recordIds,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to destroy many ${objectName}`,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { basename, dirname, extname } from 'path';
|
||||
import { type Stream } from 'stream';
|
||||
import { type Readable } from 'stream';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { buildSignedPath } from 'twenty-shared/utils';
|
||||
@@ -28,7 +28,7 @@ export class FileService {
|
||||
folderPath: string,
|
||||
filename: string,
|
||||
workspaceId: string,
|
||||
): Promise<Stream> {
|
||||
): Promise<Readable> {
|
||||
const workspaceFolderPath = `workspace-${workspaceId}/${folderPath}`;
|
||||
|
||||
return await this.fileStorageService.read({
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ export const HttpRequestInputZodSchema = z.object({
|
||||
});
|
||||
|
||||
export const HttpToolParametersZodSchema = z.object({
|
||||
toolDescription: z
|
||||
loadingMessage: z
|
||||
.string()
|
||||
.describe(
|
||||
"A clear, human-readable status message describing the HTTP request being made. This will be shown to the user while the tool is being called, so phrase it as a present-tense status update (e.g., 'Making a GET request to ...'). Explain what endpoint you are calling and with what parameters in natural language.",
|
||||
|
||||
@@ -38,15 +38,23 @@ export class HttpTool implements Tool {
|
||||
|
||||
const response = await axios(axiosConfig);
|
||||
|
||||
return { result: response.data };
|
||||
return {
|
||||
success: true,
|
||||
message: `HTTP ${method} request to ${url} completed successfully`,
|
||||
result: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `HTTP ${method} request to ${url} failed`,
|
||||
error: error.response?.data || error.message || 'HTTP request failed',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `HTTP ${method} request to ${url} failed`,
|
||||
error: error instanceof Error ? error.message : 'HTTP request failed',
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ export const SendEmailInputZodSchema = z.object({
|
||||
});
|
||||
|
||||
export const SendEmailToolParametersZodSchema = z.object({
|
||||
toolDescription: z
|
||||
loadingMessage: z
|
||||
.string()
|
||||
.describe(
|
||||
"A clear, human-readable status message describing the email being sent. This will be shown to the user while the tool is being called, so phrase it as a present-tense status update (e.g., 'Sending email to customer about order status'). Explain what email you are sending and to whom in natural language.",
|
||||
|
||||
+9
-2
@@ -133,14 +133,19 @@ export class SendEmailTool implements Tool {
|
||||
this.logger.log(`Email sent successfully to ${email}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Email sent successfully to ${email}`,
|
||||
result: {
|
||||
success: true,
|
||||
message: `Email sent successfully to ${email}`,
|
||||
recipient: email,
|
||||
subject: safeSubject,
|
||||
connectedAccountId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof SendEmailToolException) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to send email to ${email}`,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
@@ -148,6 +153,8 @@ export class SendEmailTool implements Tool {
|
||||
this.logger.error(`Failed to send email: ${error}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to send email to ${email}`,
|
||||
error: error instanceof Error ? error.message : 'Failed to send email',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export type ToolOutput = {
|
||||
result?: unknown;
|
||||
success: boolean;
|
||||
message: string;
|
||||
error?: string;
|
||||
result?: unknown;
|
||||
};
|
||||
|
||||
+2
-2
@@ -36,8 +36,8 @@ export class AgentChatMessageEntity {
|
||||
@Column({ type: 'enum', enum: AgentChatMessageRole })
|
||||
role: AgentChatMessageRole;
|
||||
|
||||
@Column('text')
|
||||
content: string;
|
||||
@Column({ type: 'text', nullable: true })
|
||||
rawContent: string | null;
|
||||
|
||||
@OneToMany(() => FileEntity, (file) => file.message)
|
||||
files: Relation<FileEntity[]>;
|
||||
|
||||
@@ -68,18 +68,18 @@ export class AgentChatService {
|
||||
async addMessage({
|
||||
threadId,
|
||||
role,
|
||||
content,
|
||||
rawContent,
|
||||
fileIds,
|
||||
}: {
|
||||
threadId: string;
|
||||
role: AgentChatMessageRole;
|
||||
content: string;
|
||||
rawContent: string | null;
|
||||
fileIds?: string[];
|
||||
}) {
|
||||
const message = this.messageRepository.create({
|
||||
threadId,
|
||||
role,
|
||||
content,
|
||||
rawContent,
|
||||
});
|
||||
|
||||
const savedMessage = await this.messageRepository.save(message);
|
||||
@@ -92,7 +92,7 @@ export class AgentChatService {
|
||||
}
|
||||
}
|
||||
|
||||
this.generateTitleIfNeeded(threadId, content);
|
||||
this.generateTitleIfNeeded(threadId, rawContent);
|
||||
|
||||
return savedMessage;
|
||||
}
|
||||
@@ -121,14 +121,14 @@ export class AgentChatService {
|
||||
|
||||
private async generateTitleIfNeeded(
|
||||
threadId: string,
|
||||
messageContent: string,
|
||||
messageContent: string | null,
|
||||
) {
|
||||
const thread = await this.threadRepository.findOne({
|
||||
where: { id: threadId },
|
||||
select: ['id', 'title'],
|
||||
});
|
||||
|
||||
if (!thread || thread.title) {
|
||||
if (!thread || thread.title || !messageContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+39
-13
@@ -1,8 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { type Readable } from 'stream';
|
||||
|
||||
import {
|
||||
type CoreMessage,
|
||||
type CoreUserMessage,
|
||||
@@ -31,6 +29,7 @@ import { AgentHandoffToolService } from 'src/engine/metadata-modules/agent/agent
|
||||
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 { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/agent/types/recordIdsByObjectMetadataNameSingular.type';
|
||||
import { constructAssistantMessageContentFromStream } from 'src/engine/metadata-modules/agent/utils/constructAssistantMessageContentFromStream';
|
||||
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 { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
@@ -115,6 +114,16 @@ export class AgentExecutionService {
|
||||
...(messages && { messages }),
|
||||
...(prompt && { prompt }),
|
||||
maxSteps: AGENT_CONFIG.MAX_STEPS,
|
||||
...(registeredModel.doesSupportThinking && {
|
||||
providerOptions: {
|
||||
anthropic: {
|
||||
thinking: {
|
||||
type: 'enabled',
|
||||
budgetTokens: AGENT_CONFIG.REASONING_BUDGET_TOKENS,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
@@ -233,7 +242,7 @@ export class AgentExecutionService {
|
||||
filename,
|
||||
file.workspaceId,
|
||||
);
|
||||
const fileBuffer = await streamToBuffer(fileStream as Readable);
|
||||
const fileBuffer = await streamToBuffer(fileStream);
|
||||
|
||||
if (file.type.startsWith('image')) {
|
||||
return {
|
||||
@@ -241,13 +250,33 @@ export class AgentExecutionService {
|
||||
image: fileBuffer,
|
||||
mimeType: file.type,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: 'file',
|
||||
data: fileBuffer,
|
||||
mimeType: file.type,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'file',
|
||||
data: fileBuffer,
|
||||
mimeType: file.type,
|
||||
};
|
||||
}
|
||||
|
||||
private mapMessagesToCoreMessages(
|
||||
messages: AgentChatMessageEntity[],
|
||||
): CoreMessage[] {
|
||||
return messages
|
||||
.map(({ role, rawContent }): CoreMessage => {
|
||||
if (role === AgentChatMessageRole.USER) {
|
||||
return {
|
||||
role: 'user',
|
||||
content: rawContent ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: constructAssistantMessageContentFromStream(rawContent ?? ''),
|
||||
};
|
||||
})
|
||||
.filter((message) => message.content.length > 0);
|
||||
}
|
||||
|
||||
async streamChatResponse({
|
||||
@@ -271,10 +300,7 @@ export class AgentExecutionService {
|
||||
where: { id: agentId },
|
||||
});
|
||||
|
||||
const llmMessages: CoreMessage[] = messages.map(({ role, content }) => ({
|
||||
role,
|
||||
content,
|
||||
}));
|
||||
const llmMessages: CoreMessage[] = this.mapMessagesToCoreMessages(messages);
|
||||
|
||||
let contextString = '';
|
||||
|
||||
|
||||
+9
-3
@@ -86,7 +86,14 @@ export class AgentHandoffExecutorService {
|
||||
`Successfully executed handoff to agent ${toAgentId} with response length: ${textResponse.text.length}`,
|
||||
);
|
||||
|
||||
return textResponse.text;
|
||||
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}`,
|
||||
@@ -95,8 +102,7 @@ export class AgentHandoffExecutorService {
|
||||
|
||||
return {
|
||||
success: false,
|
||||
newAgentId: handoffRequest.toAgentId,
|
||||
newAgentName: 'Unknown',
|
||||
message: `Failed to execute handoff to agent ${handoffRequest.toAgentId}`,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
+38
-65
@@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { type Response } from 'express';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { type Workspace } 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';
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
AgentException,
|
||||
AgentExceptionCode,
|
||||
} from 'src/engine/metadata-modules/agent/agent.exception';
|
||||
import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/agent/types/recordIdsByObjectMetadataNameSingular.type';
|
||||
|
||||
export type StreamAgentChatOptions = {
|
||||
@@ -45,6 +45,8 @@ export class AgentStreamingService {
|
||||
recordIdsByObjectMetadataNameSingular,
|
||||
res,
|
||||
}: StreamAgentChatOptions) {
|
||||
let rawStreamString = '';
|
||||
|
||||
try {
|
||||
const thread = await this.threadRepository.findOne({
|
||||
where: {
|
||||
@@ -74,66 +76,23 @@ export class AgentStreamingService {
|
||||
recordIdsByObjectMetadataNameSingular,
|
||||
});
|
||||
|
||||
let aiResponse = '';
|
||||
|
||||
for await (const chunk of fullStream) {
|
||||
switch (chunk.type) {
|
||||
case 'text-delta':
|
||||
aiResponse += chunk.textDelta;
|
||||
this.sendStreamEvent(res, {
|
||||
type: chunk.type,
|
||||
message: chunk.textDelta,
|
||||
});
|
||||
break;
|
||||
case 'tool-call':
|
||||
this.sendStreamEvent(res, {
|
||||
type: chunk.type,
|
||||
message: chunk.args?.toolDescription,
|
||||
});
|
||||
break;
|
||||
case 'error':
|
||||
{
|
||||
const errorMessage =
|
||||
chunk.error &&
|
||||
typeof chunk.error === 'object' &&
|
||||
'message' in chunk.error
|
||||
? chunk.error.message
|
||||
: 'Something went wrong. Please try again.';
|
||||
rawStreamString += JSON.stringify(chunk) + '\n';
|
||||
|
||||
this.sendStreamEvent(res, {
|
||||
type: 'error',
|
||||
message: errorMessage as string,
|
||||
});
|
||||
res.end();
|
||||
}
|
||||
this.logger.error(`Stream error: ${JSON.stringify(chunk)}`);
|
||||
break;
|
||||
default:
|
||||
this.logger.log(`Unknown chunk type: ${chunk.type}`);
|
||||
break;
|
||||
}
|
||||
this.sendStreamEvent(
|
||||
res,
|
||||
[
|
||||
'text-delta',
|
||||
'reasoning',
|
||||
'reasoning-signature',
|
||||
'tool-call',
|
||||
'tool-result',
|
||||
'error',
|
||||
].includes(chunk.type)
|
||||
? chunk
|
||||
: { type: chunk.type },
|
||||
);
|
||||
}
|
||||
|
||||
if (!aiResponse) {
|
||||
res.end();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.agentChatService.addMessage({
|
||||
threadId,
|
||||
role: AgentChatMessageRole.USER,
|
||||
content: userMessage,
|
||||
fileIds,
|
||||
});
|
||||
|
||||
await this.agentChatService.addMessage({
|
||||
threadId,
|
||||
role: AgentChatMessageRole.ASSISTANT,
|
||||
content: aiResponse,
|
||||
});
|
||||
|
||||
res.end();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
@@ -146,19 +105,33 @@ export class AgentStreamingService {
|
||||
this.setupStreamingHeaders(res);
|
||||
}
|
||||
|
||||
this.sendStreamEvent(res, {
|
||||
const errorChunk = {
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
});
|
||||
};
|
||||
|
||||
res.end();
|
||||
rawStreamString += JSON.stringify(errorChunk) + '\n';
|
||||
|
||||
this.sendStreamEvent(res, errorChunk);
|
||||
}
|
||||
|
||||
await this.agentChatService.addMessage({
|
||||
threadId,
|
||||
role: AgentChatMessageRole.USER,
|
||||
rawContent: userMessage,
|
||||
fileIds,
|
||||
});
|
||||
|
||||
await this.agentChatService.addMessage({
|
||||
threadId,
|
||||
role: AgentChatMessageRole.ASSISTANT,
|
||||
rawContent: rawStreamString.trim() || null,
|
||||
});
|
||||
|
||||
res.end();
|
||||
}
|
||||
|
||||
private sendStreamEvent(
|
||||
res: Response,
|
||||
event: { type: string; message: string },
|
||||
): void {
|
||||
private sendStreamEvent(res: Response, event: object): void {
|
||||
res.write(JSON.stringify(event) + '\n');
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -1,3 +1,4 @@
|
||||
export const AGENT_CONFIG = {
|
||||
MAX_STEPS: 10,
|
||||
REASONING_BUDGET_TOKENS: 12000,
|
||||
};
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const AGENT_HANDOFF_SCHEMA = z.object({
|
||||
toolDescription: z
|
||||
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.',
|
||||
|
||||
+2
-2
@@ -14,8 +14,8 @@ export class AgentChatMessageDTO {
|
||||
@Field()
|
||||
role: 'user' | 'assistant';
|
||||
|
||||
@Field()
|
||||
content: string;
|
||||
@Field({ nullable: true })
|
||||
rawContent: string;
|
||||
|
||||
@Field(() => [FileDTO])
|
||||
files: FileDTO[];
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ const createToolSchema = (
|
||||
return jsonSchema({
|
||||
type: 'object',
|
||||
properties: {
|
||||
toolDescription: {
|
||||
loadingMessage: {
|
||||
type: 'string',
|
||||
description:
|
||||
'A clear, human-readable description of the action being performed. Explain what operation you are executing and with what parameters in natural language.',
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { type TextPart } from 'ai';
|
||||
|
||||
type ReasoningPart = {
|
||||
type: 'reasoning';
|
||||
text: string;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
export const constructAssistantMessageContentFromStream = (
|
||||
rawContent: string,
|
||||
) => {
|
||||
const lines = rawContent.trim().split('\n');
|
||||
|
||||
const output: Array<TextPart | ReasoningPart> = [];
|
||||
let reasoningText = '';
|
||||
let textContent = '';
|
||||
|
||||
for (const line of lines) {
|
||||
let event;
|
||||
|
||||
try {
|
||||
event = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'reasoning':
|
||||
reasoningText += event.textDelta || '';
|
||||
break;
|
||||
|
||||
case 'reasoning-signature':
|
||||
if (reasoningText) {
|
||||
output.push({
|
||||
type: 'reasoning',
|
||||
text: reasoningText,
|
||||
signature: event.signature,
|
||||
});
|
||||
reasoningText = '';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'text-delta':
|
||||
textContent += event.textDelta || '';
|
||||
break;
|
||||
|
||||
default:
|
||||
if (textContent) {
|
||||
output.push({
|
||||
type: 'text',
|
||||
text: textContent,
|
||||
});
|
||||
textContent = '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
+9
-6
@@ -137,18 +137,21 @@ This is the most efficient way for AI to create workflows as it handles all the
|
||||
}
|
||||
|
||||
return {
|
||||
workflowId,
|
||||
workflowVersionId,
|
||||
name: parameters.name,
|
||||
trigger: parameters.trigger,
|
||||
steps: parameters.steps,
|
||||
success: true,
|
||||
message: `Workflow "${parameters.name}" created successfully with ${parameters.steps.length} steps`,
|
||||
result: {
|
||||
workflowId,
|
||||
workflowVersionId,
|
||||
name: parameters.name,
|
||||
trigger: parameters.trigger,
|
||||
steps: parameters.steps,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
message: `Failed to create workflow "${parameters.name}": ${error.message}`,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { type Readable } from 'stream';
|
||||
|
||||
export const streamToBuffer = async (stream: Readable): Promise<Buffer> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const chunks: any[] = [];
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
stream.on('end', () => {
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
|
||||
stream.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
+5
-5
@@ -229,7 +229,7 @@ describe('AgentToolGeneratorService Integration', () => {
|
||||
);
|
||||
|
||||
expectSuccessResult(result, 'Successfully created testObject');
|
||||
expect(result.record).toEqual(testRecord);
|
||||
expect(result.result).toEqual(testRecord);
|
||||
expect(mockRepository.save).toHaveBeenCalledWith({
|
||||
name: 'Test Record',
|
||||
description: 'Test description',
|
||||
@@ -317,8 +317,8 @@ describe('AgentToolGeneratorService Integration', () => {
|
||||
);
|
||||
|
||||
expectSuccessResult(result, 'Found 3 testObject records');
|
||||
expect(result.records).toEqual(testRecords);
|
||||
expect(result.count).toBe(3);
|
||||
expect(result.result.records).toEqual(testRecords);
|
||||
expect(result.result.count).toBe(3);
|
||||
expect(mockRepository.find).toHaveBeenCalledWith({
|
||||
where: {},
|
||||
take: 10,
|
||||
@@ -365,7 +365,7 @@ describe('AgentToolGeneratorService Integration', () => {
|
||||
);
|
||||
|
||||
expectSuccessResult(result, 'Found testObject record');
|
||||
expect(result.record).toEqual(testRecord);
|
||||
expect(result.result).toEqual(testRecord);
|
||||
expect(mockRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: 'test-record-id' },
|
||||
});
|
||||
@@ -506,7 +506,7 @@ describe('AgentToolGeneratorService Integration', () => {
|
||||
);
|
||||
|
||||
expectSuccessResult(result, 'Successfully updated testObject');
|
||||
expect(result.record).toEqual(updatedRecord);
|
||||
expect(result.result).toEqual(updatedRecord);
|
||||
expect(mockRepository.update).toHaveBeenCalledWith('test-record-id', {
|
||||
name: 'New Name',
|
||||
description: 'New description',
|
||||
|
||||
@@ -29,6 +29,7 @@ export {
|
||||
IconBrackets,
|
||||
IconBracketsAngle,
|
||||
IconBracketsContain,
|
||||
IconBrain,
|
||||
IconBrandDaysCounter,
|
||||
IconBrandGithub,
|
||||
IconBrandGoogle,
|
||||
|
||||
@@ -91,6 +91,7 @@ export {
|
||||
IconBrackets,
|
||||
IconBracketsAngle,
|
||||
IconBracketsContain,
|
||||
IconBrain,
|
||||
IconBrandDaysCounter,
|
||||
IconBrandGithub,
|
||||
IconBrandGoogle,
|
||||
|
||||
Reference in New Issue
Block a user