import { v } from 'convex/values'; import { Id } from '../_generated/dataModel'; import { ActionCtx, internalQuery } from '../_generated/server'; import { LLMMessage, chatCompletion } from '../util/llm'; import * as memory from './memory'; import { api, internal } from '../_generated/api'; import * as embeddingsCache from './embeddingsCache'; import { GameId, conversationId, playerId } from '../aiTown/ids'; import { NUM_MEMORIES_TO_SEARCH } from '../constants'; const selfInternal = internal.agent.conversation; export async function startConversationMessage( ctx: ActionCtx, worldId: Id<'worlds'>, conversationId: GameId<'conversations'>, playerId: GameId<'players'>, otherPlayerId: GameId<'players'>, ) { const { player, otherPlayer, agent, otherAgent, lastConversation } = await ctx.runQuery( selfInternal.queryPromptData, { worldId, playerId, otherPlayerId, conversationId, }, ); const embedding = await embeddingsCache.fetch( ctx, `${player.name} is talking to ${otherPlayer.name}`, ); const memories = await memory.searchMemories( ctx, player.id as GameId<'players'>, embedding, Number(process.env.NUM_MEMORIES_TO_SEARCH) || NUM_MEMORIES_TO_SEARCH, ); const memoryWithOtherPlayer = memories.find( (m) => m.data.type === 'conversation' && m.data.playerIds.includes(otherPlayerId), ); const prompt = [ `You are ${player.name}, and you just started a conversation with ${otherPlayer.name}.You should act and speak as a human, and answer with natural and short answers.`, ]; prompt.push(...agentPrompts(otherPlayer, agent, otherAgent ?? null)); prompt.push(...previousConversationPrompt(otherPlayer, lastConversation)); prompt.push(...relatedMemoriesPrompt(memories)); if (memoryWithOtherPlayer) { prompt.push( `Be sure to include some detail or question about a previous conversation in your greeting.`, ); } prompt.push(`${player.name}:`); const { content } = await chatCompletion({ messages: [ { role: 'user', content: prompt.join('\n'), }, ], max_tokens: 50, stream: true, stop: stopWords(otherPlayer.name, player.name), }); return content; } export async function continueConversationMessage( ctx: ActionCtx, worldId: Id<'worlds'>, conversationId: GameId<'conversations'>, playerId: GameId<'players'>, otherPlayerId: GameId<'players'>, ) { const { player, otherPlayer, conversation, agent, otherAgent } = await ctx.runQuery( selfInternal.queryPromptData, { worldId, playerId, otherPlayerId, conversationId, }, ); const now = Date.now(); const started = new Date(conversation.created); const embedding = await embeddingsCache.fetch( ctx, `What do you think about ${otherPlayer.name}?`, ); const memories = await memory.searchMemories(ctx, player.id as GameId<'players'>, embedding, 3); const prompt = [ `You are ${player.name}, and you're currently in a conversation with ${otherPlayer.name}.`, `The conversation started at ${started.toLocaleString()}. It's now ${now.toLocaleString()}.`, ]; prompt.push(...agentPrompts(otherPlayer, agent, otherAgent ?? null)); prompt.push(...relatedMemoriesPrompt(memories)); prompt.push( `Below is the current chat history between you and ${otherPlayer.name}.`, `DO NOT greet them again. Do NOT use the word "Hey" too often. Your response should be brief and within 200 characters.`, ); const llmMessages: LLMMessage[] = [ { role: 'user', content: prompt.join('\n'), }, ...(await previousMessages( ctx, worldId, player, otherPlayer, conversation.id as GameId<'conversations'>, )), ]; llmMessages.push({ role: 'user', content: `${player.name}:` }); const { content } = await chatCompletion({ messages: llmMessages, max_tokens: 50, stream: true, stop: stopWords(otherPlayer.name, player.name), }); return content; } export async function leaveConversationMessage( ctx: ActionCtx, worldId: Id<'worlds'>, conversationId: GameId<'conversations'>, playerId: GameId<'players'>, otherPlayerId: GameId<'players'>, ) { const { player, otherPlayer, conversation, agent, otherAgent } = await ctx.runQuery( selfInternal.queryPromptData, { worldId, playerId, otherPlayerId, conversationId, }, ); const prompt = [ `You are ${player.name}, and you're currently in a conversation with ${otherPlayer.name}.`, `You've decided to leave the question and would like to politely tell them you're leaving the conversation.`, ]; prompt.push(...agentPrompts(otherPlayer, agent, otherAgent ?? null)); prompt.push( `Below is the current chat history between you and ${otherPlayer.name}.`, `How would you like to tell them that you're leaving? Your response should be brief and within 200 characters.`, ); const llmMessages: LLMMessage[] = [ { role: 'user', content: prompt.join('\n'), }, ...(await previousMessages( ctx, worldId, player, otherPlayer, conversation.id as GameId<'conversations'>, )), ]; llmMessages.push({ role: 'user', content: `${player.name}:` }); const { content } = await chatCompletion({ messages: llmMessages, max_tokens: 50, stream: true, stop: stopWords(otherPlayer.name, player.name), }); return content; } function agentPrompts( otherPlayer: { name: string }, agent: { identity: string; plan: string } | null, otherAgent: { identity: string; plan: string } | null, ): string[] { const prompt = []; if (agent) { prompt.push(`About you: ${agent.identity}`); prompt.push(`Your goals for the conversation: ${agent.plan}`); } if (otherAgent) { prompt.push(`About ${otherPlayer.name}: ${otherAgent.identity}`); } return prompt; } function previousConversationPrompt( otherPlayer: { name: string }, conversation: { created: number } | null, ): string[] { const prompt = []; if (conversation) { const prev = new Date(conversation.created); const now = new Date(); prompt.push( `Last time you chatted with ${ otherPlayer.name } it was ${prev.toLocaleString()}. It's now ${now.toLocaleString()}.`, ); } return prompt; } function relatedMemoriesPrompt(memories: memory.Memory[]): string[] { const prompt = []; if (memories.length > 0) { prompt.push(`Here are some related memories in decreasing relevance order:`); for (const memory of memories) { prompt.push(' - ' + memory.description); } } return prompt; } async function previousMessages( ctx: ActionCtx, worldId: Id<'worlds'>, player: { id: string; name: string }, otherPlayer: { id: string; name: string }, conversationId: GameId<'conversations'>, ) { const llmMessages: LLMMessage[] = []; const prevMessages = await ctx.runQuery(api.messages.listMessages, { worldId, conversationId }); for (const message of prevMessages) { const author = message.author === player.id ? player : otherPlayer; const recipient = message.author === player.id ? otherPlayer : player; llmMessages.push({ role: 'user', content: `${author.name} to ${recipient.name}: ${message.text}`, }); } return llmMessages; } export const queryPromptData = internalQuery({ args: { worldId: v.id('worlds'), playerId, otherPlayerId: playerId, conversationId, }, handler: async (ctx, args) => { const world = await ctx.db.get(args.worldId); if (!world) { throw new Error(`World ${args.worldId} not found`); } const player = world.players.find((p) => p.id === args.playerId); if (!player) { throw new Error(`Player ${args.playerId} not found`); } const playerDescription = await ctx.db .query('playerDescriptions') .withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('playerId', args.playerId)) .first(); if (!playerDescription) { throw new Error(`Player description for ${args.playerId} not found`); } const otherPlayer = world.players.find((p) => p.id === args.otherPlayerId); if (!otherPlayer) { throw new Error(`Player ${args.otherPlayerId} not found`); } const otherPlayerDescription = await ctx.db .query('playerDescriptions') .withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('playerId', args.otherPlayerId)) .first(); if (!otherPlayerDescription) { throw new Error(`Player description for ${args.otherPlayerId} not found`); } const conversation = world.conversations.find((c) => c.id === args.conversationId); if (!conversation) { throw new Error(`Conversation ${args.conversationId} not found`); } const agent = world.agents.find((a) => a.playerId === args.playerId); if (!agent) { throw new Error(`Player ${args.playerId} not found`); } const agentDescription = await ctx.db .query('agentDescriptions') .withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('agentId', agent.id)) .first(); if (!agentDescription) { throw new Error(`Agent description for ${agent.id} not found`); } const otherAgent = world.agents.find((a) => a.playerId === args.otherPlayerId); let otherAgentDescription; if (otherAgent) { otherAgentDescription = await ctx.db .query('agentDescriptions') .withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('agentId', otherAgent.id)) .first(); if (!otherAgentDescription) { throw new Error(`Agent description for ${otherAgent.id} not found`); } } const lastTogether = await ctx.db .query('participatedTogether') .withIndex('edge', (q) => q .eq('worldId', args.worldId) .eq('player1', args.playerId) .eq('player2', args.otherPlayerId), ) // Order by conversation end time descending. .order('desc') .first(); let lastConversation = null; if (lastTogether) { lastConversation = await ctx.db .query('archivedConversations') .withIndex('worldId', (q) => q.eq('worldId', args.worldId).eq('id', lastTogether.conversationId), ) .first(); if (!lastConversation) { throw new Error(`Conversation ${lastTogether.conversationId} not found`); } } return { player: { name: playerDescription.name, ...player }, otherPlayer: { name: otherPlayerDescription.name, ...otherPlayer }, conversation, agent: { identity: agentDescription.identity, plan: agentDescription.plan, ...agent }, otherAgent: otherAgent && { identity: otherAgentDescription!.identity, plan: otherAgentDescription!.plan, ...otherAgent, }, lastConversation, }; }, }); function stopWords(otherPlayer: string, player: string) { // These are the words we ask the LLM to stop on. OpenAI only supports 4. const variants = [`${otherPlayer} to ${player}`]; return variants.flatMap((stop) => [stop + ':', stop.toLowerCase() + ':']); }