Skip to content

PRD: explicit per-message state machine for the Discord message pipeline #41

Description

@cgrpa

Problem Statement

The current Discord message handler is doing too much at once: routing, session lookup, engagement-mode branching, prompt assembly, tool execution, typing keepalive, response rendering, message sending, and post-send bookkeeping. That makes the flow hard to reason about, hard to test in isolation, and hard to evolve without risking regressions.

We want a clearer architecture for processing a single incoming Discord message without turning conversation/session lifecycle into the state machine itself.

Solution

Introduce a transient, per-message state machine that models the message-processing flow for one eligible Discord message. The machine should begin only after trivial prefilters and orchestration have already happened. It should receive a resolved bot, a resolved session when applicable, and a mutable per-turn context object.

The machine should focus on the message turn itself:

  • build prompt and chat context through a helper
  • execute tools with a bounded loop
  • make an engagement-mode response decision only when applicable
  • render a single response payload
  • send the response to Discord
  • commit post-send bookkeeping
  • fail terminally on error

Session lifecycle remains with the session manager/session model. Typing indicators and image-generation scope remain as thin runtime wrappers around the machine, not states inside it.

User Stories

  1. As a maintainer, I want message handling to be broken into explicit stages, so that I can reason about the flow without reading one large orchestration method.
  2. As a maintainer, I want the message pipeline to model one incoming message at a time, so that the architecture stays focused on turn processing instead of bot lifecycle.
  3. As a maintainer, I want trivial prefilters like ping replies, bot-authored messages, and obvious command messages to remain outside the machine, so that the state machine only handles eligible bot-processing turns.
  4. As a maintainer, I want bot routing and session resolution to happen before the machine starts, so that the machine can operate on already-decided inputs.
  5. As a maintainer, I want engagement mode and direct-prefix mode to share one machine, so that common steps are reused instead of duplicated.
  6. As a maintainer, I want the machine to branch internally by mode, so that direct-prefix and engagement behavior remain explicit but still share the same turn model.
  7. As a maintainer, I want context assembly to be represented as a dedicated stage, so that prompt preparation is separated from LLM execution.
  8. As a user, I want attachments and reply-chain context to be incorporated consistently into the prompt, so that the bot can respond using the full relevant message context.
  9. As a maintainer, I want the prompt assembly logic to live in a helper, so that the machine stays readable and the prompt-building code can be tested independently.
  10. As a maintainer, I want the helper to build the full prompt package for the turn, so that system instructions, version metadata, session context, and mode-specific additions are assembled in one place.
  11. As a maintainer, I want the prompt context to be mutable during the turn, so that tool calls can append additional history or results without rebuilding the whole prompt from scratch.
  12. As a maintainer, I want a dedicated tool-execution stage, so that function calling and manual continuation are separated from prompt assembly and response rendering.
  13. As a user, I want tool use to happen automatically when the bot needs it, so that the bot can consult search, weather, image generation, or other plugins without manual intervention.
  14. As a maintainer, I want the tool loop to have a clear maximum turn count, so that the bot cannot spin indefinitely on tool calls.
  15. As a maintainer, I want tool execution failures to terminate the turn cleanly, so that broken tool interactions do not leave the pipeline in an ambiguous state.
  16. As a maintainer, I want engagement-mode decision making to happen only after tool execution has settled, so that the bot decides based on the final context rather than partial intermediate context.
  17. As a maintainer, I want direct-prefix turns to skip the engagement decision stage, so that forced responses remain simpler and faster.
  18. As a maintainer, I want response rendering to produce a single payload object, so that text, embeds, and image bytes stay synchronized.
  19. As a maintainer, I want response rendering to be separate from response sending, so that final payload construction can be validated before anything is posted to Discord.
  20. As a user, I want the bot to send a coherent final response, so that text, embed, and image output are delivered together when available.
  21. As a maintainer, I want the machine to treat successful Discord delivery as the gate to post-send bookkeeping, so that internal state only commits after the response exists in the channel.
  22. As a maintainer, I want post-send bookkeeping to live in a dedicated stage, so that session history, counters, status updates, and image markers are committed in one consistent place.
  23. As a maintainer, I want the session manager to remain the owner of session lifecycle, so that start/active/expired/replaced logic does not leak into the message machine.
  24. As a maintainer, I want the machine to end in a generic failed terminal state on error, so that failures are simple to reason about and always logged.
  25. As a maintainer, I want the failure path to keep the exception available for logging, so that diagnostics remain available without introducing user-facing fallback complexity.
  26. As a maintainer, I want typing indicators to remain a thin runtime wrapper around the machine, so that they do not become state-machine behavior unless their semantics become more dynamic later.
  27. As a maintainer, I want image-generation scope to remain a thin runtime wrapper around the machine, so that generated images can still be associated with the turn without complicating routing or state definitions.
  28. As a maintainer, I want the turn context to be mutable and message-scoped, so that the machine can carry runtime facts, prompt context, response payload, and failure information through the turn.
  29. As a maintainer, I want the architecture to keep direct-prefix and engagement-mode behavior aligned where possible, so that future changes do not duplicate the entire pipeline.
  30. As a maintainer, I want the handler to become a thin orchestration layer, so that the core turn-processing logic is easier to reuse, inspect, and test.

Implementation Decisions

  • Introduce a new transient state machine for eligible incoming messages only; it models a single message turn, not the bot or conversation lifecycle.
  • Keep session lookup and session replacement outside the machine. The state machine receives a resolved session when applicable.
  • Keep trivial prefilters outside the machine, including bot-authored messages, ping replies, and obvious command-like messages.
  • Use one machine for both direct-prefix and engagement-mode flows, with an early mode branch inside the turn pipeline.
  • Define the machine stages as: ContextBuilding, ToolExecution, DecisionMaking (engagement only), ResponseRendering, ResponseSending, PostSend, and terminal Failed.
  • Treat ContextBuilding as the stage for assembling the full prompt package, including session history, reply-chain enrichment, image attachment context, engagement instructions, and version metadata.
  • Move prompt assembly into a dedicated helper that returns a richer, mutable turn context rather than a bare chat-history object.
  • Allow the prompt context to be mutated during the turn so that tool calls and intermediate results can append to the running context.
  • Keep runtime wrappers such as typing keepalive and image-generation scope outside the machine, while still applying them around the machine invocation.
  • Keep ToolExecution explicit and bounded, using a capped continuation loop for function-calling turns.
  • Restrict engagement-mode decision making to the post-tool phase only; direct-prefix turns do not pass through a separate decision stage.
  • Make ResponseRendering produce a single response payload object that carries the final text, embed, image bytes, blob name, and any relevant generation metadata.
  • Make ResponseSending responsible only for confirming Discord delivery of the rendered payload.
  • Make PostSend the place where successful turns commit session history, counters, image history markers, and status updates.
  • Keep the failure path terminal and log the exception object in the turn context for diagnostics.
  • Preserve the session manager as the owner of session lifecycle concepts such as started, active, expired, and replaced.
  • Preserve DynamicStatusService as a post-success side effect consumer rather than as part of routing or prompt assembly.

Testing Decisions

  • Write tests around external behavior of the new turn pipeline, not implementation details such as private state transitions or internal helper ordering.
  • Test the new machine with representative scenarios: direct-prefix success, engagement-mode success with a decision to respond, engagement-mode decision to stay silent, tool-call continuation, image-assisted response, send failure, and generic failure.
  • Test the prompt-context helper independently, because it is a deep module that should be stable, deterministic, and easy to validate in isolation.
  • Test the handler/orchestrator boundary to ensure trivial rejects stay outside the machine and that routing/session resolution happen before machine execution.
  • Test post-send bookkeeping with session and status side effects to verify that successful turns commit exactly what was sent.
  • Reuse the existing ConversationSessionManager test style as prior art: isolated service tests that verify observable session behavior without depending on Discord or model calls.
  • Reuse the existing service-test patterns used for bot registry and image-generation services: narrow, deterministic tests with scripted dependencies and explicit assertions about emitted behavior.

Out of Scope

  • Reworking the overall bot architecture beyond the message-turn pipeline.
  • Changing the semantics of bot personalities, system prompts, or model selection.
  • Replacing the existing session manager with a new persistence layer.
  • Making typing keepalive or image-generation scope into first-class machine states unless future requirements demand it.
  • Introducing a user-facing error recovery conversation for all failures.
  • Redesigning the Discord command system or broader bot command routing.
  • Changing the current session timeout and activity policy semantics unless the new pipeline exposes a clear bug.

Further Notes

The intent of this PRD is to make the message pipeline easier to reason about without over-abstracting the existing behavior. The key architectural line is that the machine owns one message turn, while the session manager owns conversation lifecycle.

The machine should be intentionally small in scope and should prefer explicit stages only where they materially improve readability, testability, or failure handling.

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-agentFully specified, ready for an AFK agent

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions