LemonRouter

September 2, 2026 ยท View on GitHub

lemon_router owns request normalization, conversation identity, queue semantics, and semantic output tracking. It sits between channel transports and lemon_gateway.

Current Flow

Channel transport or gateway-native ingress
  -> LemonRouter.Router.handle_inbound/1
  -> LemonRouter.RunOrchestrator
  -> LemonRouter.SessionCoordinator
  -> LemonRouter.RunProcess
  -> configured LemonCore.EngineRuntime.submit_execution/1
  -> gateway runtime scheduler / thread worker / run
  -> LemonCore.Bus run events
  -> LemonRouter semantic coalescers / output tracking
  -> LemonCore.DeliveryIntent
  -> LemonChannels.Dispatcher
  -> channel-specific renderer / outbox

Ownership

  • Router owns:
    • RunRequest normalization
    • policy, model, and engine resolution
    • resume resolution and conversation-key selection
    • queue semantics: collect, followup, steer, steer_backlog, interrupt
    • transactional pending-compaction prompt rewriting
    • semantic stream and tool-status coalescing
  • Router does not own:
    • Telegram or Discord rendering details
    • OutboundPayload construction
    • Telegram message-id presentation state
    • gateway slot scheduling or engine lifecycle

Key Modules

ModuleResponsibility
LemonRouter.RouterMain inbound entrypoint, session-key resolution, pending-compaction preparation, control-plane abort/keepalive hooks
LemonRouter.RunOrchestrator (internal)Durably claims fixed run IDs, rejects conflicting reuse, serializes abort registration with cancellation dispatch, and hands accepted work to SessionCoordinator
LemonRouter.SessionCoordinatorSingle owner of per-conversation queue semantics and active-run handoff
Router internal session read modelInternal read model over coordinator-owned active session state
LemonRouter.ConversationKeyCanonical conversation-key selection from structured resume or session key
LemonRouter.ResumeResolverStructured resume resolution before runtime submission
LemonRouter.RunProcessActive-run lifecycle wrapper around one execution
LemonRouter.MediaJobRecorderRecords generated final-answer files into redacted media job metadata before channel delivery
LemonRouter.ChannelsDelivery (internal)Narrow bridge from router-adjacent automation delivery requests into LemonChannels; must not construct OutboundPayload or own channel rendering
LemonRouter.StreamCoalescerSemantic answer coalescing that emits DeliveryIntent snapshots/finalization
LemonRouter.ToolStatusCoalescerSemantic tool-status coalescing that emits DeliveryIntent snapshots/finalization; chat surfaces hide reasoning-only actions and fixed engine provenance while operator surfaces retain structured reasoning
LemonRouter.PendingCompactionShared pending-compaction preparation, injection-safe JSONL history envelopes, whole-entry truncation, and post-submit marker consumption
LemonRouter.PendingCompactionStoreRouter-owned typed wrapper for pending-compaction markers
LemonRouter.AgentEndpointStoreRouter-owned typed wrapper for persistent endpoint aliases
LemonRouter.AgentInboxBEAM-local send API with selectors, fanout, and queue-mode selection
LemonRouter.AgentDirectoryActive/durable session discovery
LemonRouter.AgentEndpointsPersistent route aliases

SurfaceManager.finalize_answer/3 dispatches :stream_finalize first for runs that already streamed answer deltas, then finalizes StreamCoalescer state so late flushes cannot overwrite the final. For non-streamed completions it still finalizes through StreamCoalescer, with a direct :final_text fallback if the coalescer finalize call exits or times out. Completion-time artifact metadata enrichment is best-effort and must not block the final answer path. RunProcess.ArtifactTracker also supports Hermes-style final-answer media directives: a line containing MEDIA:<project-relative-path> is converted into an explicit auto_send_files entry after existing-file, cwd, and symlink escape checks pass. The directive line is removed from the final text before channel rendering, and Telegram/Discord delivery still goes through the normal attachment policy.

Important Contracts

  • Router startup validates the configured :engine_runtime through LemonCore.EngineRuntime.validate/1. Invalid wiring is logged once and its binding is disabled without changing the router-only degraded mode: the router still boots and runtime operations retain their existing unavailable-runtime handling.
  • Inbound callers should provide structured resume data through LemonCore.RunRequest.resume when they already know it.
  • Router.handle_inbound/1 returns the exact RunOrchestrator.submit/1 error when submission is rejected. Inbound transports must not acknowledge a message as accepted after that error, and pending-compaction markers remain available for a later valid submission. A malformed acknowledgement is {:error, :outcome_unknown} because submission may already have happened without returning a usable run id; it is not automatically retry-safe.
  • Abort and keep-alive :ok results mean the router accepted or dispatched the decision. A run-specific abort is not acknowledged unless its serialization tombstone was registered; an unknown registration outcome is propagated to the bridge. These commands do not wait for synchronous application by every target run process.
  • Top-level runs always use the native executor; model validation belongs to LemonAi, and default cwd resolution should use LemonCore.Cwd.
  • Router emits LemonCore.DeliveryIntent, not LemonChannels.OutboundPayload.
  • Tool-status failure summaries preserve safe structured fields from action.detail.result_meta, including error_type, tool name, timeout, command exit code, exception class/name, status, reason, message, and validation errors.
  • Cron/channel-origin summary delivery may cross router through the internal LemonRouter.ChannelsDelivery bridge, but the bridge must stay narrow. Router must not construct LemonChannels.OutboundPayload; LemonChannels remains responsible for enqueue semantics, retries, chunking, and adapter delivery.
  • Runtime input is LemonCore.ExecutionCommand; gateway-private adapters may translate it to LemonGateway.ExecutionRequest.
  • Telegram-specific state is owned by lemon_channels wrappers:
    • LemonChannels.Telegram.StateStore
    • LemonChannels.Telegram.ResumeIndexStore
  • External apps must query busy/active session state through LemonRouter.Router or LemonCore.RouterBridge, not router-internal read-model or registry details. Registry/read-model failures propagate as errors rather than false idle/empty answers.

Session And Queue Semantics

SessionCoordinator serializes by conversation key:

  • {:resume, engine, token} when a structured resume token is available
  • {:session, session_key} otherwise

Queue-mode behavior lives here:

  • :collect appends
  • :followup debounces/merges recent followups, except async task/delegated followups which stay separate
  • :steer attempts in-run steer and falls back to followup
  • :steer_backlog attempts in-run steer and falls back to collect
  • active async task/delegated auto-followups are promoted to :steer so completions try to reach the live parent run before falling back to a queued followup
  • :interrupt cancels the active run and inserts the new request at the front

Pending compaction is coupled to this submission boundary. Fresh markers remain stored while the compacted prompt is prepared and are deleted only after the coordinator accepts the submission, so admission/start errors remain retryable. Stale markers and markers with no usable history clear eagerly. Prior run summaries are carried as JSONL with escaped envelope characters and are bounded by dropping complete oldest run entries rather than slicing through role data.

Run admission is terminally bounded at both router boundaries. RunProcess backs off through transient runtime outages only until its pre-start deadline; persistent unavailability or rejection emits one structured synthetic :run_completed event so the active conversation is released. If a queued child cannot start later, SessionCoordinator emits the corresponding structured start-failure completion before cleaning up its event-bridge subscription and continues with the next queued submission. A child already registered after an ambiguous supervisor error is adopted to avoid double completion.

Fixed run IDs are router idempotency keys, not merely correlation labels. The orchestrator writes a durable request-identity claim before queue mutation; concurrent or crash-replayed copies of the same request resolve to the original active, queued, or completed run, while reuse for another session or payload is rejected. A pending claim is reconciled against surviving RunRegistry state and the durable terminal RunStore record before any retry can enqueue work. SessionRegistry remains an ephemeral read model: submission reconstructs an idle coordinator from surviving authoritative run processes after a registry restart so the one-run-per-session rule still holds.

Output Semantics

Router coalescers only track semantic state:

  • accumulated text
  • sequence numbers
  • semantic tool/action state
  • run/session metadata needed for DeliveryIntent

Channels decides:

  • send vs edit
  • truncation
  • reply markup
  • media batching
  • Telegram resume indexing by platform message id

Testing

Run the app suite from the umbrella root:

mix test apps/lemon_router

Useful focused suites during refactors:

mix test apps/lemon_router/test/lemon_router/router_test.exs
mix test apps/lemon_router/test/lemon_router/run_orchestrator_test.exs
mix test apps/lemon_router/test/lemon_router/session_coordinator_test.exs
mix test apps/lemon_router/test/lemon_router/run_process_test.exs
mix test apps/lemon_router/test/lemon_router/stream_coalescer_test.exs
mix test apps/lemon_router/test/lemon_router/tool_status_coalescer_test.exs

Run architecture checks after boundary changes:

mix lemon.quality