Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 74 additions & 19 deletions functions/src/chat/chat.agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ import {
shouldUseMessageFormat,
MessageRole,
} from './message_converter.utils';
import {updateParticipantReadyToEndChat} from '../chat/chat.utils';
import {
updateParticipantReadyToEndChat,
sendSystemPrivateChatMessage,
} from '../chat/chat.utils';
import {
getExperimenterDataFromExperiment,
getFirestorePublicStageChatMessages,
Expand Down Expand Up @@ -219,6 +222,35 @@ export async function getAgentChatMessage(
// Confirm that agent can send chat messages based on prompt config
const chatSettings = promptConfig.chatSettings;
if (!canSendAgentChatMessage(user.publicId, chatSettings, chatMessages)) {
// If a mediator in a private chat can no longer respond (e.g., maxResponses
// reached), send a system message so the frontend stops showing the spinner
// and agent participants get unblocked. (Fixes #1011)
// Use trigger log to send only once per mediator.
if (
stage.kind === StageKind.PRIVATE_CHAT &&
user.type === UserType.MEDIATOR
) {
const leftChatLogRef = getPrivateChatTriggerLogRef(
experimentId,
participantIds[0],
stageId,
`left-chat-${user.publicId}`,
);
const alreadySent = (await leftChatLogRef.get()).exists;
if (!alreadySent) {
await leftChatLogRef.set({timestamp: Timestamp.now()});
await sendSystemPrivateChatMessage(
experimentId,
participantIds[0],
stageId,
{
message: `${user.name ?? 'Mediator'} has left the chat.`,
senderId: user.publicId,
profile: createParticipantProfileBase(user),
},
);
}
}
return {message: null, success: true};
}

Expand Down Expand Up @@ -340,15 +372,17 @@ export async function getAgentChatMessage(
}
}

// No text and no files = failure
if (!response.text && (!response.files || response.files.length === 0)) {
// No text and no files = failure, unless the agent explicitly chose not
// to respond or signaled readyToEndChat (empty text is expected then).
if (
shouldRespond &&
!readyToEndChat &&
!response.text &&
(!response.files || response.files.length === 0)
) {
return {message: null, success: false};
}

if (!shouldRespond) {
// Logic for not responding (handled below)
}

// Only if agent participant is ready to end chat
if (readyToEndChat && user.type === UserType.PARTICIPANT) {
// Ensure we don't end chat on the very first message
Expand All @@ -368,18 +402,39 @@ export async function getAgentChatMessage(
}

if (!shouldRespond) {
// If agent decides not to respond in private chat, they are ready to end
if (
stage.kind === StageKind.PRIVATE_CHAT &&
user.type === UserType.PARTICIPANT &&
chatMessages.length > 0
) {
const participantAnswerDoc = getFirestoreParticipantAnswerRef(
experimentId,
user.privateId,
stageId,
);
await participantAnswerDoc.set({readyToEndChat: true}, {merge: true});
if (stage.kind === StageKind.PRIVATE_CHAT && chatMessages.length > 0) {
if (user.type === UserType.PARTICIPANT) {
// Agent participant is ready to end chat
const participantAnswerDoc = getFirestoreParticipantAnswerRef(
experimentId,
user.privateId,
stageId,
);
await participantAnswerDoc.set({readyToEndChat: true}, {merge: true});
} else if (user.type === UserType.MEDIATOR) {
// Mediator chose not to respond — send a system message so the
// frontend spinner stops and agent participants get unblocked. (#938)
const leftChatLogRef = getPrivateChatTriggerLogRef(
experimentId,
participantIds[0],
stageId,
`left-chat-${user.publicId}`,
);
const alreadySent = (await leftChatLogRef.get()).exists;
if (!alreadySent) {
await leftChatLogRef.set({timestamp: Timestamp.now()});
await sendSystemPrivateChatMessage(
experimentId,
participantIds[0],
stageId,
{
message: `${user.name ?? 'Mediator'} has left the chat.`,
senderId: user.publicId,
profile: createParticipantProfileBase(user),
},
);
}
}
}
return {message: null, success: true};
}
Expand Down
29 changes: 29 additions & 0 deletions functions/src/chat/chat.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,35 @@ import {
} from '@deliberation-lab/utils';
import {updateParticipantNextStage} from '../participant.utils';

/** Send a system message to a private chat (e.g., mediator left the chat).
* Unlike error messages, system messages are NOT ignored by chat triggers,
* so they can wake up agent participants.
*/
export async function sendSystemPrivateChatMessage(
experimentId: string,
participantId: string,
stageId: string,
config: Partial<ChatMessage> = {},
) {
const chatMessage = createSystemChatMessage({
...config,
timestamp: Timestamp.now(),
});

const agentDocument = app
.firestore()
.collection('experiments')
.doc(experimentId)
.collection('participants')
.doc(participantId)
.collection('stageData')
.doc(stageId)
.collection('privateChats')
.doc(chatMessage.id);

await agentDocument.set(chatMessage);
}

/** Used for private chats if model response fails. */
export async function sendErrorPrivateChatMessage(
experimentId: string,
Expand Down
105 changes: 58 additions & 47 deletions functions/src/triggers/chat.triggers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,71 +144,82 @@ export const onPrivateChatMessageCreated = onDocumentCreated(
);
if (!stage) return;

// Send agent mediator messages
const participant = await getFirestoreParticipant(
event.params.experimentId,
event.params.participantId,
);
if (!participant) return;

const mediators = await getFirestoreActiveMediators(
event.params.experimentId,
participant.currentCohortId,
stage.id,
true,
);

await Promise.all(
mediators.map(async (mediator) => {
const result = await createAgentChatMessageFromPrompt(
event.params.experimentId,
participant.currentCohortId,
[participant.privateId],
stage.id,
event.params.chatId,
mediator,
);
// Send agent mediator responses to participant messages only.
// System messages (e.g., "mediator has left") should not trigger
// mediator responses — they are signals, not conversation turns.
if (
message.type !== UserType.MEDIATOR &&
message.type !== UserType.SYSTEM
) {
const mediators = await getFirestoreActiveMediators(
event.params.experimentId,
participant.currentCohortId,
stage.id,
true,
);

if (!result) {
await sendErrorPrivateChatMessage(
await Promise.all(
mediators.map(async (mediator) => {
const result = await createAgentChatMessageFromPrompt(
event.params.experimentId,
participant.privateId,
participant.currentCohortId,
[participant.privateId],
stage.id,
{
discussionId: message.discussionId,
message: 'Error fetching response',
type: mediator.type,
profile: createParticipantProfileBase(mediator),
senderId: mediator.publicId,
agentId: mediator.agentConfig?.agentId ?? '',
},
event.params.chatId,
mediator,
);
}
}),
);

// If no mediator, return error (otherwise participant may wait
// indefinitely for a response).
if (mediators.length === 0) {
await sendErrorPrivateChatMessage(
event.params.experimentId,
participant.privateId,
stage.id,
{
discussionId: message.discussionId,
message: 'No mediators found',
},
if (!result) {
await sendErrorPrivateChatMessage(
event.params.experimentId,
participant.privateId,
stage.id,
{
discussionId: message.discussionId,
message: 'Error fetching response',
type: mediator.type,
profile: createParticipantProfileBase(mediator),
senderId: mediator.publicId,
agentId: mediator.agentConfig?.agentId ?? '',
},
);
}
}),
);

// If no mediator, return error (otherwise participant may wait
// indefinitely for a response).
if (mediators.length === 0) {
await sendErrorPrivateChatMessage(
event.params.experimentId,
participant.privateId,
stage.id,
{
discussionId: message.discussionId,
message: 'No mediators found',
},
);
}
}

// Send agent participant messages (if participant is an agent)
// Send agent participant messages (if participant is an agent).
// Agent responds to mediator messages and system messages (e.g.,
// "mediator has left the chat") so it can advance stages. (#1011)
if (participant.agentConfig) {
// Ensure agent only responds to mediator, not themselves
if (message.type === UserType.MEDIATOR) {
if (
message.type === UserType.MEDIATOR ||
message.type === UserType.SYSTEM
) {
await createAgentChatMessageFromPrompt(
event.params.experimentId,
participant.currentCohortId,
[participant.privateId], // Pass agent's own ID as array
[participant.privateId],
stage.id,
event.params.chatId,
participant,
Expand Down