Skip to content
Merged
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
37 changes: 29 additions & 8 deletions src/agents/eventAssistant/eventAssistant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,14 +175,28 @@ while they wait.
channelType: 'groupChat'
})

const userPrompt = `Event: "${this.conversation.name}"
${this.conversation.description ? `Description: ${this.conversation.description}` : ''}
// Event name/description and presenter bios are organizer-authored free text that may contain
// literal braces — passed as input variables rather than spliced into the template string
// itself, so LangChain's f-string parser never tries to resolve them as template placeholders.
const userTemplate = `Event: "{eventName}"
{description}

Presenters:
${presenterLines}`
{presenterLines}`

const llm = await this.getLLM()
return getChatPromptResponse(llm, systemPrompt, userPrompt, {}, undefined, ThreeFactsSchema)
return getChatPromptResponse(
llm,
systemPrompt,
userTemplate,
{
eventName: this.conversation.name,
description: this.conversation.description ? `Description: ${this.conversation.description}` : '',
presenterLines
},
undefined,
ThreeFactsSchema
)
}

/**
Expand Down Expand Up @@ -448,14 +462,20 @@ pregame chat and the warm-up question, whether they were there for it or are jus
channelType: 'groupChat'
})

const userPrompt = `Pregame chat:
${pregameChatText || 'No pregame chat activity.'}
// Pregame chat and warm-up answers are attendee-authored free text that may contain literal
// braces — passed as input variables rather than spliced into the template string itself, so
// LangChain's f-string parser never tries to resolve them as template placeholders.
const userTemplate = `Pregame chat:
{pregameChatText}

Warm-up question answers:
${warmUpAnswersText || 'No warm-up answers.'}`
{warmUpAnswersText}`

const llm = await this.getLLM()
return getChatPromptResponse(llm, systemPrompt, userPrompt, {})
return getChatPromptResponse(llm, systemPrompt, userTemplate, {
pregameChatText: pregameChatText || 'No pregame chat activity.',
warmUpAnswersText: warmUpAnswersText || 'No warm-up answers.'
})
}

// Filter /mod and /escalate command messages and moderator_submitted replies from LLM history so
Expand Down Expand Up @@ -786,6 +806,7 @@ export default verify({
// sent as a warm-up DM (same unconfigured check as isPreEventWindowOpen) — skip straight
// past buildPregameRecapMessage's DB queries rather than running them just to find nothing.
if (typeof this.conversation.preEventMinutes !== 'number' || this.conversation.preEventMinutes <= 0) return []
if (this.agentConfig?.groupChannelOpener === false) return []

const channel = this.conversation.channels?.find((c) => c.name === currentGroupChannelName(this.conversation))
if (!channel) return []
Expand Down
76 changes: 75 additions & 1 deletion src/conversations/eventAssistant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,74 @@ const eventAssistant: ConversationType = {
userControlled: false,
agents: [],
properties: []
},
{
name: 'pregame',
label: 'Pregame Access',
description:
'Opens the room before the event starts where early arrivals can chat and answer a private warm-up question.',
default: false,
category: 'assistant',
userControlled: false,
agents: [],
properties: [
{
name: 'preEventMinutes',
label: 'Doors Open (minutes before start)',
description: 'How many minutes before the scheduled start time early access opens.',
required: false,
type: 'number',
default: 15
},
{
name: 'warmUpQuestion',
label: 'Warm-Up Question Source',
description: 'Whether the private warm-up question is written by the assistant or supplied by the organizer.',
required: false,
type: 'enum',
options: [
{ value: 'organizer', label: 'Organizer written' },
{ value: 'generated', label: 'AI generated' }
],
default: 'generated'
},
{
name: 'warmUpQuestionText',
label: 'Warm-Up Question Text',
description: 'Used only when the warm-up question source is organizer.',
required: false,
type: 'string'
},
{
name: 'warmUpQuestionFormat',
label: 'Warm-Up Question Format',
description: 'Open-ended text, or a single-choice question with fixed options. Applies to either source.',
required: false,
type: 'enum',
options: [
{ value: 'text', label: 'Open-ended text' },
{ value: 'singleChoice', label: 'Single choice' }
],
default: 'text'
},
{
name: 'warmUpQuestionOptions',
label: 'Warm-Up Question Options',
description:
'The selectable options. Used only when source is organizer and format is singleChoice — an agent-authored singleChoice question generates its own options instead.',
required: false,
type: 'array'
},
{
name: 'groupChannelOpener',
label: 'Post Pregame Recap on Start',
description:
'When the event starts, post a short recap of the pregame chat and warm-up answers to the group chat.',
required: false,
type: 'boolean',
default: true
}
]
}
],

Expand All @@ -224,7 +292,12 @@ const eventAssistant: ConversationType = {
{ $ref: 'llmModel.llmPlatform' },
{ $ref: 'botName', as: 'agentConfig.botName' },
{ $ref: 'moderatorSupport', as: 'agentConfig.moderatorSupport' },
{ $ref: 'seriesHistory', as: 'agentConfig.seriesHistory' }
{ $ref: 'seriesHistory', as: 'agentConfig.seriesHistory' },
{ $ref: 'pregame.warmUpQuestion', as: 'agentConfig.warmUpQuestion.source' },
{ $ref: 'pregame.warmUpQuestionText', as: 'agentConfig.warmUpQuestion.text' },
{ $ref: 'pregame.warmUpQuestionFormat', as: 'agentConfig.warmUpQuestion.format' },
{ $ref: 'pregame.warmUpQuestionOptions', as: 'agentConfig.warmUpQuestion.options' },
{ $ref: 'pregame.groupChannelOpener', as: 'agentConfig.groupChannelOpener' }
]
},
{
Expand All @@ -241,6 +314,7 @@ const eventAssistant: ConversationType = {
]
}
],
propertyBindings: [{ $ref: 'pregame.preEventMinutes' }],
enableDMs: ['agents'],
channels: [
{ name: TRANSCRIPT_CHANNEL },
Expand Down
16 changes: 10 additions & 6 deletions src/conversations/resolver.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import handlebars from 'handlebars'
import httpStatus from 'http-status'
import ApiError from '../utils/ApiError.js'
import { AgentProperty, BehaviorPolicy, ConversationType, Feature, FeatureConfig } from '../types/index.types.js'
import { BehaviorPolicy, ConversationType, Feature, FeatureConfig, PropertyBinding } from '../types/index.types.js'
import { isValidPropertyFormat } from './propertyFormats.js'

const FACILITATIVE_GOALS = [
Expand Down Expand Up @@ -71,6 +71,9 @@ interface ResolvedConversationConfig {
features: Feature[]
goals: string[]
behaviorPolicy: BehaviorPolicy
// conversationType.propertyBindings resolved via resolvePropertyBindings, for callers
// that want a property to land on the conversation document itself rather than agentConfig.
conversationFields: Record<string, unknown>
}

const removeEmptyValues = (obj) => {
Expand Down Expand Up @@ -110,9 +113,9 @@ const setNestedValue = (obj: Record<string, unknown>, path: string, value: unkno
current[keys[keys.length - 1]] = value
}

const resolveAgentProperties = (agentProperties: AgentProperty[], resolvedProperties: Record<string, unknown>) => {
const resolvePropertyBindings = (bindings: PropertyBinding[], resolvedProperties: Record<string, unknown>) => {
const result: Record<string, unknown> = {}
for (const prop of agentProperties) {
for (const prop of bindings) {
if ('$ref' in prop) {
const value = getNestedValue(resolvedProperties, prop.$ref)
const key = prop.as ?? prop.$ref.split('.').at(-1)!
Expand Down Expand Up @@ -279,9 +282,9 @@ const resolveAgents = (
conversationType: ConversationType,
resolvedProperties: Record<string, unknown>
): Array<{ name: string; properties?: Record<string, unknown> }> => {
const mapAgent = (agent: { name: string; properties?: AgentProperty[] }) => ({
const mapAgent = (agent: { name: string; properties?: PropertyBinding[] }) => ({
name: agent.name,
properties: agent.properties ? resolveAgentProperties(agent.properties, resolvedProperties) : undefined
properties: agent.properties ? resolvePropertyBindings(agent.properties, resolvedProperties) : undefined
})

return [
Expand Down Expand Up @@ -335,6 +338,7 @@ export default function resolveConversationType(
properties: resolvedProperties,
features,
goals,
behaviorPolicy
behaviorPolicy,
conversationFields: resolvePropertyBindings(conversationType.propertyBindings ?? [], workingProperties)
}
}
2 changes: 1 addition & 1 deletion src/docs/components.yml
Original file line number Diff line number Diff line change
Expand Up @@ -733,7 +733,7 @@ components:
type: boolean
type:
type: string
enum: [string, number, boolean, object, enum]
enum: [string, number, boolean, object, array, enum]
label:
type: string
default:
Expand Down
4 changes: 3 additions & 1 deletion src/jobs/handlers/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,9 @@ const openConversationWindow = async (job) => {
const conversationWindowCountdown = async (job) => {
const { conversationId, phase } = job.attrs.data as { conversationId: string; phase: ConversationPhase }
try {
const conversation = await Conversation.findOne({ _id: conversationId }).populate('agents')
// Only agent._id is used below (to notify agentDispatcher), so project agents down to just
// that instead of hydrating full Agent documents on every tick of every open pre-event window.
const conversation = await Conversation.findOne({ _id: conversationId }).populate('agents', '_id')
// Same ObjectId-vs-string gotcha as openConversationWindow: re-derive from the fetched doc
// when there is one, rather than trusting job.attrs.data's own type.
const conversationIdStr = conversation?._id?.toString() ?? conversationId.toString()
Expand Down
41 changes: 32 additions & 9 deletions src/services/conversation.service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { TopicDocument } from '../../models/topic.model.js'
import { getConversationType } from '../../conversations/index.js'
import adapterTypes from '../../adapters/index.js'
import resolveConversationType from '../../conversations/resolver.js'
import isPreEventWindowOpen from './preEventWindow.js'
import { supportedModels } from '../../agents/helpers/getEmbeddings.js'
import transcript from '../../agents/helpers/transcript.js'
import memberBios from '../../utils/memberBios.js'
Expand Down Expand Up @@ -279,7 +280,9 @@ const createConversationFromType = async (params, user, { allowDraft = false } =
}

const resolved = resolveConversationType(params, conversationType, allowDraft)
return createConversation({ ...params, conversationType: type, ...resolved }, user, { allowDraft })
return createConversation({ ...params, conversationType: type, ...resolved, ...resolved.conversationFields }, user, {
allowDraft
})
}

/**
Expand All @@ -303,6 +306,10 @@ const updateConversation = async (conversationBody, user) => {
if (conversationDoc.active) {
throw new ApiError(httpStatus.BAD_REQUEST, 'Cannot update an active conversation')
}

if (isPreEventWindowOpen(conversationDoc)) {
throw new ApiError(httpStatus.BAD_REQUEST, 'Cannot update a conversation during its pre-event window')
}
/* Once a Draft conversation's scheduled start time is within 6 minutes (including
after it has already passed), it stays locked from further edits: the owner
should create a new event rather than editing this one. Non-Draft conversations
Expand Down Expand Up @@ -341,6 +348,7 @@ const updateConversation = async (conversationBody, user) => {
} = conversationBody

const oldResources = incomingResources !== undefined ? [...conversationDoc.resources] : null
const oldFeatures = incomingFeatures !== undefined ? conversationDoc.features : undefined

/* updateDocument sets doc[key] = body[key] directly, so passing properties through
would overwrite all existing keys with only what the caller sent. We merge manually
Expand Down Expand Up @@ -402,6 +410,29 @@ const updateConversation = async (conversationBody, user) => {
not explicitly false. */
const enabledFeatureNames = new Set(incomingFeatures.filter((f) => f.enabled !== false).map((f) => f.name))

/* Resolved once from the new feature set, the same way createConversationFromType
resolves at creation, so a property bound onto the conversation (e.g. pregame's
preEventMinutes) is applied here too rather than only when creating agents from
scratch. Diffed against what the *previous* feature set would have resolved to so a
binding whose feature was just disabled gets cleared instead of left stale — leaving
it stale would make isPreEventWindowOpen keep reporting a window long after the
feature (and its agent) were removed. */
const resolved = resolveConversationType(
{ platforms: conversationDoc.platforms, properties: conversationDoc.properties, features: incomingFeatures },
convType
)
const previousFields = resolveConversationType(
{ platforms: conversationDoc.platforms, properties: conversationDoc.properties, features: oldFeatures ?? [] },
convType
).conversationFields
for (const key of Object.keys(previousFields)) {
if (!(key in resolved.conversationFields)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(conversationDoc as any)[key] = undefined
}
}
Object.assign(conversationDoc, resolved.conversationFields)

for (const featureDef of convType.features ?? []) {
for (const agentSpec of featureDef.agents ?? []) {
if (!enabledFeatureNames.has(featureDef.name)) {
Expand All @@ -422,14 +453,6 @@ const updateConversation = async (conversationBody, user) => {
/* Feature enabled: create the agent if it doesn't exist yet. */
const exists = await Agent.findOne({ conversation: conversationDoc._id, agentType: agentSpec.name })
if (!exists) {
const resolved = resolveConversationType(
{
platforms: conversationDoc.platforms,
properties: conversationDoc.properties,
features: incomingFeatures
},
convType
)
const agentDef = resolved.agentTypes.find((a) => a.name === agentSpec.name)
if (agentDef) {
const agent = await agentService.createAgent(agentDef.name, conversationDoc, agentDef.properties)
Expand Down
9 changes: 6 additions & 3 deletions src/types/index.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,7 @@ export interface PropertyRef {
as?: string // destination key in agent params (supports dot notation for nesting); defaults to last segment of $ref
}

export type AgentProperty = ConfigProperty | PropertyRef
export type PropertyBinding = ConfigProperty | PropertyRef

export interface ChannelConfig {
name: string
Expand All @@ -446,12 +446,12 @@ export interface ChannelConfig {

export interface AgentConfig {
name: string
properties?: AgentProperty[]
properties?: PropertyBinding[]
}

export interface FeatureAgentConfig {
name: string // agent type name
properties?: AgentProperty[] // wiring from resolved properties into agent config
properties?: PropertyBinding[] // wiring from resolved properties into agent config
}

/**
Expand Down Expand Up @@ -513,6 +513,9 @@ export interface ConversationType {
properties: ConfigProperty[]
features?: FeatureConfig[]
agents?: AgentConfig[]
// $ref/as entries resolved the same way an agent's own `properties` are, but landing
// directly on the created conversation document instead of an agent's agentConfig.
propertyBindings?: PropertyBinding[]
channels?: ChannelConfig[]
enableDMs?: string[]
adapters?: Record<string, AdapterConfig>
Expand Down
Loading
Loading