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:
RunRequestnormalization- 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
OutboundPayloadconstruction- Telegram message-id presentation state
- gateway slot scheduling or engine lifecycle
Key Modules
| Module | Responsibility |
|---|---|
LemonRouter.Router | Main 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.SessionCoordinator | Single owner of per-conversation queue semantics and active-run handoff |
| Router internal session read model | Internal read model over coordinator-owned active session state |
LemonRouter.ConversationKey | Canonical conversation-key selection from structured resume or session key |
LemonRouter.ResumeResolver | Structured resume resolution before runtime submission |
LemonRouter.RunProcess | Active-run lifecycle wrapper around one execution |
LemonRouter.MediaJobRecorder | Records 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.StreamCoalescer | Semantic answer coalescing that emits DeliveryIntent snapshots/finalization |
LemonRouter.ToolStatusCoalescer | Semantic 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.PendingCompaction | Shared pending-compaction preparation, injection-safe JSONL history envelopes, whole-entry truncation, and post-submit marker consumption |
LemonRouter.PendingCompactionStore | Router-owned typed wrapper for pending-compaction markers |
LemonRouter.AgentEndpointStore | Router-owned typed wrapper for persistent endpoint aliases |
LemonRouter.AgentInbox | BEAM-local send API with selectors, fanout, and queue-mode selection |
LemonRouter.AgentDirectory | Active/durable session discovery |
LemonRouter.AgentEndpoints | Persistent 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_runtimethroughLemonCore.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.resumewhen they already know it. Router.handle_inbound/1returns the exactRunOrchestrator.submit/1error 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
:okresults 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 useLemonCore.Cwd. - Router emits
LemonCore.DeliveryIntent, notLemonChannels.OutboundPayload. - Tool-status failure summaries preserve safe structured fields from
action.detail.result_meta, includingerror_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;LemonChannelsremains responsible for enqueue semantics, retries, chunking, and adapter delivery. - Runtime input is
LemonCore.ExecutionCommand; gateway-private adapters may translate it toLemonGateway.ExecutionRequest. - Telegram-specific state is owned by
lemon_channelswrappers:LemonChannels.Telegram.StateStoreLemonChannels.Telegram.ResumeIndexStore
- External apps must query busy/active session state through
LemonRouter.RouterorLemonCore.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:
:collectappends:followupdebounces/merges recent followups, except async task/delegated followups which stay separate:steerattempts in-run steer and falls back to followup:steer_backlogattempts in-run steer and falls back to collect- active async task/delegated auto-followups are promoted to
:steerso completions try to reach the live parent run before falling back to a queued followup :interruptcancels 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