Agents and the agent loop
How Fermix's always-on main agent, its one-at-a-time per-conversation queue, the capped agent loop that calls the model and runs tools, and the subagents tool work together to handle every turn.
This page describes what happens between a message arriving and the agent replying. Every inbound message, from any channel, flows through the same runtime: a FIFO queue (first-in, first-out: messages in a conversation are handled one at a time, in arrival order) starts a turn task, which checks out a prepared turn-state snapshot from the persistent MainAgent and runs the turn through TurnRunner. TurnRunner assembles the prompt and calls AgentLoop, which runs provider (the LLM provider, e.g. OpenAI or Anthropic) calls and tool executions in a bounded loop, meaning it has a hard cap on how many rounds it can run, until the model returns a final response or a stop condition fires.
MainAgent
MainAgent is a permanent OTP GenServer (a single, always-running supervised process). There is exactly one per running Fermix instance. It owns the runtime-context cache (prompt context, capability registry references, provider configuration) and checks out a turn-state snapshot for each incoming request.
Conversation identity
Every conversation is identified by a three-part key:
{channel, chat_id, thread_scope}
channel is the platform (:telegram, :slack, etc.). chat_id is the platform’s conversation or user id. When a channel provides a thread id (Slack thread timestamps, Telegram topics), that value is used as the thread scope; otherwise the scope is :root, meaning the plain unthreaded conversation. This key is the unit of FIFO scheduling, history storage, and compaction.
Turn context
MainAgent prepares the slow-changing parts of a turn. Its cached runtime context holds the composed prompt base (bootstrap files, prompt memory files, runtime sections including available skills, built through PromptComposer), the current capability list from Capabilities.Registry, and the provider route chain snapshotted at boot. The queue’s turn task checks that out as a turn-state snapshot and hands it to TurnRunner, which runs the turn body:
- Reads recent conversation history from
ConversationStore, and runs an optional preflight compaction pass (see below). - Composes the message list from the cached prompt base plus history, and injects a fresh current-date system note (the date is deliberately not part of the cached prompt, so it never goes stale).
- Applies the run profile if the turn was tagged (see /ultra mode), persists the user message, and runs AgentLoop.
MainAgent does not know how or where the reply is delivered. The channel’s reply_fn (a pre-built function that knows how to send the reply back to that channel) stays with the queue and is called from the turn task.
After the reply is delivered, the gateway calls TurnRunner.commit/4, which persists the assistant message to history, dispatches a background memory review (see memory), and runs the post-delivery auto-compaction check — keeping all of that off the reply path. A turn that never delivered (stopped or crashed) is never committed.
Status surface
MainAgent.status/1 reports available_skills, the configured primary provider (primary_provider) and its fallbacks (fallback_providers), the current model, and memory configuration. Queue activity (active_conversations, pending_conversations, active_requests, pending_requests) is reported by Gateway.Queue.status/1. Both are surfaced together by fermix status via the daemon control socket.
Typing indicator
While a turn is running, Fermix sends a typing indicator to the channel every 4 seconds. The indicator stops after 5 minutes regardless of turn state.
Auto-compaction
After each turn, Fermix checks the conversation’s token usage using the real token count the provider reported for that turn, not a local estimate. When that context-token count reaches threshold times the model’s context window (the maximum amount of text the model can consider at once; default threshold 0.85), Fermix compacts the conversation. The same provider-reported count carried from the prior turn also gates a preflight pass before the next turn’s first model call; a conversation with no prior measurement (a cold first turn, or the first turn after a daemon restart) skips that preflight trigger, and the post-turn pass handles it instead. Compaction means older messages are summarized and replaced with the summary plus recent turns, to free up room. On compaction failure, Fermix backs off for 60 seconds before retrying. When the preflight pass compacts, a short notice is delivered to the user (“Trimmed older conversation history to stay within the context window.”); the post-turn pass runs after the reply and, when it compacts, delivers its own one-line notice (“🗜️ Trimmed older conversation history to stay within the context window — long-term memory is kept.”). If a turn overflows the context window anyway, the error reply points at /new (fresh session, long-term memory kept) or /compact (summarize this one). See prompt and compaction for details.
[fermix_core.compaction]
enabled = true
threshold = 0.85
Gateway.Queue
Gateway.Queue is the FIFO turn scheduler between the channel layer and MainAgent. It is a permanent GenServer started by FermixChannels.Application.
Each conversation key has its own FIFO queue. At most one turn is active at a time per conversation key. Incoming messages queue behind the active turn; they are not dropped or replaced. Different conversation keys run independently and concurrently.
When the active turn finishes (or crashes), the queue starts the next pending request. A crashed turn that never delivered a reply sends a generic error message to the user before clearing. If the model returns an empty completion and the turn delivered nothing else, the user gets “I didn’t get a response — please try again.” A turn that already delivered a non-text side-effect (an emoji-reaction acknowledgement or an attachment) treats that as the reply and closes silently with a compact history marker. In both cases the empty completion is never committed to history, so it cannot poison later turns.
Freshness is enforced at delivery time: a turn is only allowed to deliver its reply and commit to history if its task pid is still the conversation’s active pid. Turns cancelled via /stop are suppressed at this boundary: they neither deliver nor commit. Because the user’s message was already persisted at turn start, the gateway closes it with a short assistant marker (“The previous request was stopped before I finished it. It is kept here for context only…”) so the next turn keeps it in history but does not replay and answer it.
AgentLoop
AgentLoop is a stateless, bounded LLM-and-tool loop. It is not a process; it runs inline within the turn task. It receives messages, the provider route, the capability list, and an optional allowed-tool filter. It is used by interactive turns, subagent workers, and scheduled jobs.
Per-iteration flow
Each iteration of the loop:
- Call
adapter.chat/3(first iteration) oradapter.continue/3(subsequent iterations). The adapter returns%{content, tool_calls, usage, provider_state, model}. - Emit
[:fermix, :agent, :iteration]telemetry. - If
tool_callsis empty, return the final result. - Run loop detection against the most recent tool-call signatures.
- Execute each tool call: parse arguments, check the allowed-tool list, look up and dispatch the capability.
- Pass tool results back to
adapter.continue/3and repeat.
Iteration cap
The loop enforces a hard cap on tool-call iterations. The default caps by context:
| Context | Default cap |
|---|---|
| Interactive turns | 100 |
| Subagent workers | 100 |
| Scheduled jobs | 100 |
| Ultra subagent workers | 40 |
When the cap is reached, the loop returns an error and the turn delivers a failure reply (“I hit the investigation step limit before finishing that request. Try narrowing the ask…”). These limits are system-side configuration and are not exposed as config.toml keys.
Loop detection
The loop tracks a sliding window of recent tool-call signatures. A signature is {tool_name, normalized_arguments}. The window size, warn threshold, and kill threshold are configurable via Memory.Config; defaults are:
| Parameter | Default |
|---|---|
| Window size | 10 most-recent signatures |
| Warn at | 3 repeats of the same signature |
| Kill at | 5 repeats of the same signature |
When the warn threshold is crossed, the loop threads a one-time warning into the next model continuation (“Repeated tool call warning: … Do not call it again unless the arguments or plan meaningfully change.”). When the kill threshold is crossed, the loop aborts the turn with a “Repeated tool call loop detected” error. Arguments are normalized (JSON with sorted keys) before comparison, so the same call written two ways still counts as a repeat.
External content isolation
Tool results that return external content (network tools, MCP servers, which are external tool servers Fermix connects to, browser output, plugin-owned tools, and screenshots or UI text from the experimental computer-use capability) are wrapped in an <untrusted_tool_result> fence before being sent back to the provider. The fence instructs the model to treat the content as data, not instructions, and any wrapper tags inside the external payload are defanged so attacker content cannot close the fence early. Results from internal tools (for example subagents, whose reports are Fermix-authored) stay unwrapped.
Provider adapter contract
Provider adapters implement two callbacks:
@callback chat(messages, capabilities, adapter_opts) ::
{:ok, turn} | {:error, term}
@callback continue(provider_state, tool_results, adapter_opts) ::
{:ok, turn} | {:error, term}
provider_state threads the provider’s transcript representation across iterations. The loop does not interpret or store provider-specific wire formats directly. Failover to the next provider in the configured chain is only eligible on the initial chat/3 call and only when no streaming content has already been delivered to the user. Before any failover hop, that initial call first gets a bounded same-provider retry with short exponential backoff on transient infrastructure errors (a connection_unavailable pool-checkout / wake-from-sleep race, a transport timeout/close, or a provider 5xx), so a brief flake self-heals on the same provider instead of burning a failover hop. Continuation calls never fail over, but they get a narrower in-place retry of their own: when a continuation times out and the adapter measured that no response data had arrived yet, the loop re-issues that one call on the same route, up to twice, waiting 2 seconds and then 4. No tool is run again, nothing already sent to the user is re-sent, and the provider never changes; any other continuation error fails on its first failure. Scheduled runs get the same in-place retry. When a rate-limit or quota error is terminal — no failover hop is available, whether because it is the only route, the chain is exhausted, or the constraints above rule a hop out — and its response carries a reset time, the error reply is a friendly “You’ve hit your <provider> usage limit. Try again in ~N min.” instead of a generic failure. See providers and models.
Subagents
The subagents tool lets the main agent fan out work to temporary worker agents running concurrently. Each worker gets a goal-level task description, runs its own bounded AgentLoop under AgentSupervisor, and returns its findings. The main agent synthesizes the results.
Subagents run at the parent turn’s trust level with the parent’s policy classes minus :read_write and :gui_control. Workers can read files, fetch from the web, use MCP and plugin tools, and run skills and sandbox-bounded shell commands. They cannot write local state, reply on the parent’s channel, or access the parent’s memory or sandbox handle.
Subagents cannot call subagents recursively. The tool requires a source_trust in the calling context — present for main-agent turns and for scheduled-job runs, so an operator-created cron job can fan out to workers too (guest-created jobs never see the tool) — and it errors immediately if called from within a worker.
Default caps for a regular subagents call:
| Parameter | Default | Hard maximum |
|---|---|---|
| Tasks per call | 10 | 10 |
| Concurrent workers | 4 | 8 |
| Per-worker timeout | 300 s | 900 s |
| Per-worker iterations | 100 | 100 |
| Total result bytes | 60 000 | 60 000 |
Each worker runs under a fixed system prompt telling it that it is a temporary subagent handling one narrow slice of work, with no user contact and instructions to use as few tool calls as it can. Result verbosity is controlled by the result_format argument (concise, detailed, or structured; default structured).
Per-call model routing: pass model and optionally provider and reasoning_effort (how much thinking the model does before answering, higher meaning more deliberate and slower) arguments to override the configured subagent model for that call only. See providers and models for routing configuration.
Cancelled parent turns (via /stop) propagate to in-flight subagent workers via a parent-process monitor on each AgentServer.
Agent definitions
Each agent (Main or subagent worker) is described by an AgentDefinition struct:
| Field | Meaning |
|---|---|
role |
:main or :sub |
persistent |
true for MainAgent; false for ephemeral workers |
system_prompt |
Prepended to every conversation for this agent |
provider / model / temperature / reasoning_effort |
Provider routing for this agent (reasoning_effort is set only through subagent routing, never from skill frontmatter) |
allowed_tools |
nil = trust default, [] = none, [..] = exact list |
policy |
Policy class overrides |
trust |
:operator or :guest |
max_iterations / timeout_seconds |
Iteration and wall-clock bounds |
parent / delegates_to |
Allowed delegate targets |
Skills are a specialization of agent definition with role: :sub and persistent: false. See skills.
/ultra mode
/ultra <prompt> runs the next turn in exhaustive multi-agent mode. It is owner-only and available in every channel.
The command tags the turn with run_profile: :ultra. The ordinary agent loop runs for the tagged turn, but the subagents tool advertises wider caps and the prompt receives an exhaustive-mode addendum. /ultra is not a separate orchestrator or a different code path.
Ultra caps:
| Parameter | Ultra default |
|---|---|
| Max tasks per subagents call | 50 |
| Max concurrency | 12 |
| Per-worker iterations | 40 |
| Total result bytes | 300 000 |
These caps are system-side configuration and are not exposed as config.toml keys.
SkillRegistry
Skills are discovered from three filesystem roots on startup:
| Root | Trust |
|---|---|
priv/skills (bundled with the release) |
:operator |
~/.fermix/skills (local) |
:operator |
Enabled plugins’ skill directories under ~/.fermix/plugins/ |
:guest |
Bundled and locally installed skills run at operator trust (the operator vetted them by installing); plugin-loaded skills, and anything outside the known roots, fail closed to guest trust (capability-restricted). Each skill is mirrored into Capabilities.Registry as kind: :skill. The registry snapshot is held in memory and can be refreshed in place, without a restart, via the skill_reload tool or fermix skills reload after a SKILL.md is created or edited on disk. See skills for skill authoring and the skill_run, skill_list, skill_view, skill_create, and skill_reload built-in tools.
Related pages
- Capabilities and tools: the full built-in capability list, policy classes, and trust levels.
- Providers and models: provider configuration, model routing, fallback chains, and subagent model overrides.
- Prompt and compaction: prompt composition order, bootstrap files, and conversation compaction.