5.1 KiB
Core Message Flow Architecture
This document outlines the complete lifecycle of a message within the Satori Bot, from the initial WebSocket event to the LLM's decision-making process.
1. Architectural Overview
The bot operates on a Event-Driven + Autonomous Loop hybrid model:
- Event Layer: Handles raw WebSocket signals, deduplication, and queuing.
- Scheduler Layer: Consumes the queue, updates the internal "Unread Pool" state, and triggers channel-specific processing loops.
- Planner Layer: The LLM acts as an Agent that observes the "Unread Pool" and "History Actions" state to decide whether to
read_unread_messages(observe) orsend_message(act).
2. Detailed Data Flow
Phase 1: Ingress
Location: src/adapter/satori/client.ts → src/core/loop/queue.ts
- WebSocket Reception: The
SatoriClientreceives a raw JSON signal and parses it into aSatoriEvent. - Event Listener: The
setupMessageEventHandler(inqueue.ts) listens formessage-createdevents. - Deduplication: The system checks the
processedIdsset (key:channelId-messageId) to prevent double-processing. - Enqueuing:
- The raw
eventis wrapped into a{ event, status: 'ready' }object. - It is pushed into
botContext.eventQueueand persisted to the database viapushToEventQueue. - Key Data:
event.message.content,event.user.id,event.channel.id.
- The raw
Phase 2: Consumption & Anchoring
Location: src/core/loop/scheduler.ts (Function: onMessageArrival)
When the system processing lock is free, it consumes events from the eventQueue:
- Context Initialization:
- Extracts
channelId. - Calls
ensureChatContext(insrc/core/session/context.ts) to load or create the in-memoryChatContextfor that channel. - Anchor: The
event.channel.idis the primary key for all context.
- Extracts
- Filtering:
- Checks
selfId. If the sender is the bot itself, the event is removed from the queue and discarded (not counted as unread).
- Checks
- State Update (The "Unread Pool"):
- The event is pushed into
botContext.unreadEvents[channelId]and persisted to the database viapushToUnreadEvents. - The event is then removed from the database queue via
removeFromEventQueue.
- The event is pushed into
- Loop Trigger:
- Immediately calls
loopIterationForChannel, waking up the Agent Loop for this specific channel.
- Immediately calls
Phase 3: Reasoning (LLM)
Location: src/core/loop/scheduler.ts → src/core/planner/llm-client.ts
The LLM is prompted not to "reply to this text," but to "decide the next action based on state."
- Context Construction (
imagineAnAction):- System: Injects
system-action-gen-v1(Tool Definitions) andpersonality-v1(Persona). - Short-term Memory: Injects
chatContext.messages(Recent conversation turns). - Global State (Crucial): The prompt explicitly states: "You have X unread events." and lists the contents of
botContext.unreadEvents. - Incoming Injection: If there is an incoming message stream, it is injected as an
Incoming eventsblock at the end of the prompt. - Action History: Injects
chatContext.actionsto show the results of previous attempts (e.g., "Last action: read_messages, Result: Success").
- System: Injects
- Generation:
- The LLM outputs a strictly formatted JSON Action, e.g.,
{"action": "read_unread_messages", "channelId": "..."}.
- The LLM outputs a strictly formatted JSON Action, e.g.,
Phase 4: Dispatch & Execution
Location: src/core/dispatcher.ts → src/capabilities/registry.ts
The system looks up the corresponding Handler in globalRegistry based on the JSON Action:
-
Case:
read_unread_messages(src/capabilities/actions/read-messages.ts)- Logic: Retrieves all backlog events from
botContext.unreadEventsfor the specified channel. - Formatting: Converts them into a single text block (e.g.,
[User]: Content). - Result: Stores this text in the
Action Result. - State Change: Clears the
unreadEventsfor that channel. In the next Tick, the LLM will see this text in its History Actions and generate a reply.
- Logic: Retrieves all backlog events from
-
Case:
send_message(src/capabilities/actions/send-message.ts)- Safety Check: Checks
unreadEventsagain. If new messages arrived during generation, it might abort the send to prioritize reading. - Execution: Calls
satoriClient.sendMessage. - Recording: Persists the response to the DB and the in-memory
messagesarray.
- Safety Check: Checks
Phase 5: Loop Continuation
Location: src/core/loop/scheduler.ts (handleLoopStep)
dispatchActionreturns anActionResultcontaining ashouldContinueflag.- If
shouldContinueis true (e.g., usually true after reading messages, as a reply is expected), the scheduler waits forLOOP_CONTINUE_DELAY_MS(default 2.5s) and then recursively callshandleLoopStep. - Hard Limit: To prevent infinite loops caused by LLM hallucinations or API abuse, each loop is capped at
MAX_LOOP_ITERATIONS = 5. Reaching this limit will force the loop to break. - Termination: The loop stops when the LLM selects a terminal action, the iteration limit is reached, or the
shouldContinueflag becomes false.