How Modules Connect: Behaviour-Based Dependency Injection
The Problem
Gleam has no traits, protocols, or interfaces. Core modules need to call services and plugins, but the project's import policy forbids core/ from importing directly from services/ or plugins/. This keeps core decoupled as a pure wireframe -- but core still needs to call LLMClient.chat(), SessionStore.load_session(), Guardrail.check(), and so on.
The Solution
Records of function fields serve as structural contracts. Each one is a behaviour record -- a custom type defined in core/behaviours/behaviours.gleam whose fields are function references. Every service or plugin that wants to connect to core exposes a constructor function that returns a populated behaviour record. Core modules accept these records as parameters and call the functions on them.
The Pattern (Step by Step)
1. Define the behaviour type in core
In core/behaviours/behaviours.gleam:
pub type SessionStore {
SessionStore(
load_session: fn(Connection, String) -> Result(Option(#(SessionRow, List(Message))), String),
create_session: fn(Connection, String, String, String) -> Result(String, String),
append_message: fn(Connection, String, Message) -> Result(Nil, String),
// ... 16 functions total
)
}2. Implement the real logic in a service
In services/storage/session_db.gleam:
pub fn load_session(conn: Connection, session_key: String)
-> Result(Option(#(SessionRow, List(Message))), String) {
// Real SQLite queries here
}3. Export a constructor via a companion *_behaviour.gleam module
In services/storage/session_db_behaviour.gleam:
import core/behaviours/behaviours.{type SessionStore, SessionStore}
import services/storage/session_db
pub fn session_store() -> SessionStore {
SessionStore(
load_session: session_db.load_session,
create_session: session_db.create_session,
append_message: session_db.append_message,
// ...
)
}The companion module exists to avoid import cycles: core/behaviours/ must not import services/, so the construction step needs its own module inside services/.
4. Core modules accept the behaviour record as a parameter
In core/loop/orchestrator.gleam (the pure conversation loop):
import core/behaviours/behaviours.{type SessionStore}
pub fn loop(
ctx: LoopContext, // LoopContext contains session_store: SessionStore
// ...
) -> LoopResult {
case ctx.session_store.load_session(conn, session_key) {
Ok(Some(#(row, messages))) -> // ...
}
}5. Wiring happens at startup
In agent_supervisor.gleam:
// Construct all behaviour records
let log = logger_behaviour.logger()
let session_store = session_db_behaviour.session_store()
let guardrail = guardrails_behaviour.guardrail()
let db_connector = db_behaviour.db_connector()
// Pass them into the gateway supervisor (which passes them further down)
gateway_supervisor.start(log, conn, completions_client, ..., session_store, guardrail, db_connector, ...)The Complete Wiring Flow
agent_supervisor.gleam
|-- constructs: Logger, SessionStore, Guardrail, DbConnector
|-- passes to --> plugins/gateways/supervisor.gleam
| |-- builds GatewayConfig with Logger, SessionStore, Guardrail, DbConnector
| |-- starts Telegram gateway plugin
|
|-- passes to --> services/supervisor/supervisor.gleam
|-- constructs: LLMClient, MemoryStore
|-- starts: Pulse, Cron (each gets LLMClient, MemoryStore, DbConnector)
|-- initializes: Harness, Notifications
Per-request wiring (gateway handler or CLI loop):
agent.gleam
|-- constructs: ContextBuilder, TokenEstimator, CompressorHook, GuardrailHook
|-- passes into --> core/loop/runner.gleam (composition root)
|-- builds LoopContext {
| llm_client, session_store, memory_store, guardrail,
| logger, context_builder, token_estimator,
| compressor_hook, guardrail_hook, db_connector, ...
| }
|-- passes to --> core/loop/orchestrator.gleam (pure conversation loop)All 13 behaviour records are constructed at the top level and threaded down. Each layer only constructs what it needs and passes the rest through.
All 13 Behaviour Records
| # | Behaviour | Purpose |
|---|---|---|
| 1 | LLMClient | Chat completions, retry with jittered backoff, error classification. chat: fn(Client, List(Message), List(ToolDefinition)) -> Result(ChatResponse, ClientError), chat_with_retry, is_retryable, should_compress |
| 2 | SessionStore | 16 CRUD functions for session persistence: load_session, create_session, append_message, touch_session, end_session, generate_id, load_session_row, set_session_title, list_sessions, search_messages, find_session_for_message, resolve_root_session, load_messages_range, list_sessions_rich, delete_session, export_session_json, find_session_by_title, list_recently_active, prune_sessions |
| 3 | MemoryStore | Memory CRUD: format_for_system_prompt, add_entry, get_entries, replace_entry, remove_entry, usage_string |
| 4 | Guardrail | Command safety classification: check (classifies commands), classify_risk, strip_ansi, is_approved_pd, consume_approval_pd, cache_approval_pd |
| 5 | Logger | Structured logging: debug, info, warn, error -- each takes (LogCategory, String, String) |
| 6 | ContextBuilder | System prompt assembly: build_tool_continuation, system_prompt |
| 7 | TokenEstimator | Token counting via character-based heuristics: estimate_request, context_usage_percent, context_window_warning |
| 8 | CompressorHook | History summarization: should_compress, split_history, build_summary_prompt, apply_compression, is_cooling_down, record_success, record_failure |
| 9 | GuardrailHook | Tool-loop circuit breaker: fresh, record_success, record_failure, escalation, block_tool, halt |
| 10 | ReflectionHook | Post-turn review: should_reflect, spawn_review |
| 11 | DndChecker | Do-not-disturb rules: is_quiet_now, list_active, add_rule, toggle_indefinite, remove_all |
| 12 | CronStore | Cron job persistence: list_enabled, list_all, upsert, delete, touch |
| 13 | DbConnector | SQLite lifecycle: open, close, checkpoint, integrity_check, migrate |
Constructor Modules
| Constructor module | Returns |
|---|---|
services/storage/db_behaviour.gleam | DbConnector |
services/storage/session_db_behaviour.gleam | SessionStore |
services/storage/memory_db.gleam | MemoryStore |
services/guardrails/guardrails_behaviour.gleam | Guardrail |
services/logger/logger_behaviour.gleam | Logger |
services/context/context.gleam | ContextBuilder |
services/tokens/tokens.gleam | TokenEstimator |
services/notifications/dnd_behaviour.gleam | DndChecker |
services/storage/cron_db.gleam | CronStore |
plugins/hooks/context_compressor/context_compressor_behaviour.gleam | CompressorHook |
plugins/hooks/tool_guardrails/tool_guardrails_behaviour.gleam | GuardrailHook |
plugins/hooks/reflection/reflection.gleam | ReflectionHook |
Note
The LLMClient is the only behaviour without a dedicated companion *_behaviour.gleam module. Its constructor llm_client() lives directly in services/api/openai/completions.gleam -- the same module that implements the chat logic.