ai-townCode Deep DiveAgent Behavior

File Analysis: convex/aiTown/agent.ts

This file is the “brain” for individual AI agents. It defines the Agent class, which encapsulates an agent’s state and its complex decision-making logic, dictating their actions and social interactions at every moment.

1. Core Role and Positioning

This file serves as the “brain” for individual AI agents within the AI Town simulation. It defines the Agent class, which encapsulates an agent’s state and decision-making logic. Its primary responsibility is to determine what an agent should do at each moment (or “tick”) of the game loop, orchestrating everything from idle behavior to complex social interactions.

2. Core Value and Process

The core value of this file is to implement the agent’s lifecycle and behavioral state machine. On each game tick, it evaluates the agent’s current situation (e.g., are they idle, in a conversation, moving?) and decides on the next appropriate action. This includes initiating new plans, responding to conversation invites, navigating the world to meet other agents, and participating in dialogue. The process ensures that agents behave in a logical, stateful, and responsive manner.

A simplified state machine for an agent’s behavior looks like this:

3. Code Deep Dive

Class: Agent

The Agent class is the blueprint for every AI character in the simulation. It holds all the information that defines an agent’s current state, such as its unique ID, its associated player ID, any ongoing tasks, and its social status. The core logic of the agent’s “brain” is contained within this class, primarily in the tick() method.

export class Agent {
  id: GameId<'agents'>;
  playerId: GameId<'players'>;
  toRemember?: GameId<'conversations'>;
  lastConversation?: number;
  lastInviteAttempt?: number;
  inProgressOperation?: {
    name: string;
    operationId: string;
    started: number;
  };
 
  constructor(serialized: SerializedAgent) {
    const { id, lastConversation, lastInviteAttempt, inProgressOperation } = serialized;
    const playerId = parseGameId('players', serialized.playerId);
    this.id = parseGameId('agents', id);
    this.playerId = playerId;
    this.toRemember =
      serialized.toRemember !== undefined
        ? parseGameId('conversations', serialized.toRemember)
        : undefined;
    this.lastConversation = lastConversation;
    this.lastInviteAttempt = lastInviteAttempt;
    this.inProgressOperation = inProgressOperation;
  }
// ... rest of the class methods

Method: tick(game, now)

This method is the heartbeat of the AI agent, called by the game engine for every agent on every “tick” of the simulation. It’s a large state machine that determines what the agent should do based on its current situation.

Execution Flow:

  1. Check for Busy State: It first checks if the agent is already performing a long-running action (like waiting for an LLM). If so, it waits, but will time out the operation if it takes too long.
  2. Decide to Act (If Idle): If the agent is free and idle, it triggers the agentDoSomething operation. This is the prompt for the agent to form a new high-level plan, like walking somewhere or starting a conversation.
  3. Process Memories: If the agent has just finished a conversation, it prioritizes remembering it by triggering the agentRememberConversation operation.
  4. Manage Conversations: If the agent is in a conversation, it enters a detailed logic block to handle social interactions based on its status (invited, walkingOver, or participating). This is where it accepts invites, moves towards partners, and decides when to speak by triggering the agentGenerateMessage operation.
  tick(game: Game, now: number) {
    const player = game.world.players.get(this.playerId);
    if (!player) {
      throw new Error(`Invalid player ID ${this.playerId}`);
    }
    if (this.inProgressOperation) {
      if (now < this.inProgressOperation.started + ACTION_TIMEOUT) {
        // Wait on the operation to finish.
        return;
      }
      console.log(`Timing out ${JSON.stringify(this.inProgressOperation)}`);
      delete this.inProgressOperation;
    }
    const conversation = game.world.playerConversation(player);
    const member = conversation?.participants.get(player.id);
 
    // ... (Idle logic to trigger agentDoSomething)
 
    // Check to see if we have a conversation we need to remember.
    if (this.toRemember) {
      //... (triggers agentRememberConversation)
      return;
    }
    if (conversation && member) {
      // ... (handles all conversation states: invited, walkingOver, participating)
    }
  }

Method: startOperation(...)

This is a crucial helper method that initiates a long-running, asynchronous backend task, such as calling an LLM. It “locks” the agent by setting the inProgressOperation property, preventing it from trying to do two things at once. It then uses game.scheduleOperation() to tell the Convex backend to run the specified task (e.g., agentGenerateMessage) in the background.

  startOperation<Name extends keyof AgentOperations>(
    game: Game,
    now: number,
    name: Name,
    args: Omit<FunctionArgs<AgentOperations[Name]>, 'operationId'>,
  ) {
    if (this.inProgressOperation) {
      throw new Error(
        `Agent ${this.id} already has an operation: ${JSON.stringify(this.inProgressOperation)}`,
      );
    }
    const operationId = game.allocId('operations');
    console.log(`Agent ${this.id} starting operation ${name} (${operationId})`);
    game.scheduleOperation(name, { operationId, ...args } as any);
    this.inProgressOperation = {
      name,
      operationId,
      started: now,
    };
  }

Function: agentSendMessage(...)

This backend mutation is the final step in an agent’s communication loop. After an LLM generates a message, this function is called. It inserts the new message into the database and then pushes an agentFinishSendingMessage event into the game’s input queue. This event signals the engine to clear the inProgressOperation lock on the agent, allowing it to think about its next action.

export const agentSendMessage = internalMutation({
  args: {
    worldId: v.id('worlds'),
    conversationId,
    agentId,
    playerId,
    text: v.string(),
    messageUuid: v.string(),
    leaveConversation: v.boolean(),
    operationId: v.string(),
  },
  handler: async (ctx, args) => {
    await ctx.db.insert('messages', {
      conversationId: args.conversationId,
      author: args.playerId,
      text: args.text,
      messageUuid: args.messageUuid,
      worldId: args.worldId,
    });
    await insertInput(ctx, args.worldId, 'agentFinishSendingMessage', {
      conversationId: args.conversationId,
      agentId: args.agentId,
      timestamp: Date.now(),
      leaveConversation: args.leaveConversation,
      operationId: args.operationId,
    });
  },
});

Function: findConversationCandidate(...)

This backend query helps an agent decide who to talk to. When an agent wants to socialize, this function is called to find the best candidate. It filters out players the agent has talked to recently, sorts the remaining candidates by physical distance, and returns the closest one.

export const findConversationCandidate = internalQuery({
  args: {
    now: v.number(),
    worldId: v.id('worlds'),
    player: v.object(serializedPlayer),
    otherFreePlayers: v.array(v.object(serializedPlayer)),
  },
  handler: async (ctx, { now, worldId, player, otherFreePlayers }) => {
    const { position } = player;
    const candidates = [];
 
    for (const otherPlayer of otherFreePlayers) {
      // Find the latest conversation we're both members of.
      const lastMember = await ctx.db
        .query('participatedTogether')
        .withIndex('edge', (q) =>
          q.eq('worldId', worldId).eq('player1', player.id).eq('player2', otherPlayer.id),
        )
        .order('desc')
        .first();
      if (lastMember) {
        if (now < lastMember.ended + PLAYER_CONVERSATION_COOLDOWN) {
          continue;
        }
      }
      candidates.push({ id: otherPlayer.id, position });
    }
 
    // Sort by distance and take the nearest candidate.
    candidates.sort((a, b) => distance(a.position, position) - distance(b.position, position));
    return candidates[0]?.id;
  },
});