MLX (on-device)¶
DeepAgentsMLX brings on-device inference to DeepAgents via Apple's
MLX framework. All computation runs on the
Neural Engine and GPU of the host Mac - no network call, no API key, no token metering.
Platform requirements
Apple Silicon (arm64) is required. DeepAgentsMLX will not build or run on Intel Macs.
macOS 26+ (Tahoe), Swift 6.1+, and Xcode 26+ are required. Xcode must be the builder because
it emits MLX's Metal shader library (.metallib) as part of the build; swift build alone
does not.
Model catalog¶
MlxModel is a value type that describes a downloadable model. A static catalog ships with the
package:
public struct MlxModel: Identifiable, Sendable {
public enum Kind { case language, vision, retriever }
public enum Family { case lfm2, lfm2VL, lfm2ColBERT, ornith, qwen36, gemma4 }
public let id: String
public let displayName: String
public let detail: String
public let kind: Kind
public let approxGB: Double
public let acceptsImages: Bool
public var isVision: Bool
public var agentParameters: GenerateParameters
public var contextWindowTokens: Int
public var maxOutputTokens: Int?
public static let catalog: [MlxModel]
public static var languageCatalog: [MlxModel]
public static var retrieverCatalog: [MlxModel]
}
Describing a row in a UI¶
Everything a model list needs is derived from the row, so a catalog addition needs no matching change in the UI that lists it:
| Member | What it gives you |
|---|---|
family / Family.title |
The family the row belongs to ("LFM2.5", "Qwen3.6", "LFM2.5-ColBERT"), keyed off the repo id |
variantName |
displayName without the family prefix - "1.2B Thinking", for a listing that already has a family heading |
qualifiedName |
Family then row - "LFM2.5 · 1.2B Instruct" - for chrome that shows one model on its own (a status line, a banner) |
roleLabel |
"Text", "Vision", "Embedding" - or "Text + Vision" for a unified VLM cataloged .language that also takes images (Ornith) |
quantizationLabel |
The weight format read off the id: "8-bit", "bf16", "MXFP4", "OptiQ 4-bit" |
capabilityLabel |
What the model is for - detail's leading component, e.g. "Reasoning + Vision" |
sizeLabel |
The download size, "410 MB" / "9.0 GB" |
contextWindowTokens / maxOutputTokens |
The card's context window, and the tokens one turn may generate (nil for a .retriever, which generates nothing) |
MlxModel.sections(of:) groups any subset of the catalog for display - one Section per family,
chat models first, then the vision families, then the retrieval encoders, preserving catalog order
inside each. Section.roleLabel is its rows' role, so a heading says what the family is for.
Ripple's /model Local tab and ripple model list are both built on it:
for section in MlxModel.sections(of: MlxModel.catalog) {
print("\(section.family.title) · \(section.roleLabel)")
for model in section.models {
print(" \(model.variantName) \(model.quantizationLabel) \(model.sizeLabel)")
}
}
LFM2.5 family¶
LFM2.5 models run with a 32k-token context window, except the 2.6B, which is run at the full 128k
its model card documents. Recommended sampling parameters are baked into each
MlxModel.agentParameters entry so you can pass them straight through.
| Model | Kind | approx. size | Notes |
|---|---|---|---|
| LFM2.5 350M | language | ~0.4 GB | Below the reliable tool-call floor; useful for classification/summarization tasks without tools |
| LFM2.5 1.2B Instruct | language | ~1.3 GB (8-bit) / ~2.5 GB (bf16) | General-purpose; reliable tool calling |
| LFM2.5 1.2B Thinking | language | ~1.3 GB (8-bit) / ~2.4 GB (bf16) | Reasoning mode; uses top_p 0.1 sampling; higher maxTokens (8192) to fit <think> blocks |
| LFM2.5 2.6B | language | ~1.6 GB (MXFP4) / ~2.8 GB (MXFP8) / ~2.9 GB (8-bit) / ~5.4 GB (bf16) | General-purpose 2.69B; 128k context; repetition_penalty 1.1 rather than the family's 1.05. Reasons in <think> blocks, so maxTokens is 8192 and startsInReasoning is set. Four precisions in one repo - see below |
| LFM2.5 8B-A1B | language | ~9 GB | Mixture-of-experts; strongest tool-calling in the family |
| LFM2.5 VL 450M | vision | ~0.6 GB (8-bit) / ~1.0 GB (bf16) | Vision-language; supportsVision: true |
| LFM2.5 VL 1.6B | vision | ~2.1 GB | Vision-language; higher accuracy than 450M |
Precision subfolders
Most catalog ids are Hugging Face repo ids. LFM2.5 2.6B is the exception: LiquidAI publishes
every precision as a subfolder of one repo, so its ids carry a third component naming the
precision - LiquidAI/LFM2.5-2.6B-MLX/mxfp4, …/mxfp8, …/8bit, …/bf16.
The id stays the unique selection key; MlxModelID splits it, and MlxModelLoader scopes the
download to that subfolder and loads from snapshots/<hash>/<precision>/. Each precision
downloads, reports downloaded state, and deletes independently even though all four share one
cache directory. MXFP4 and MXFP8 are MXFP microscaling formats; MXFP4 keeps
model.embed_tokens at MXFP8 via a per-layer quantization override.
Choosing a model
For most agentic tasks start with LFM2.5 1.2B Instruct - small enough to keep on a
16 GB Mac without noticeable memory pressure, reliable enough for multi-step tool use. Step up
to 8B-A1B when you need stronger reasoning or complex nested tool calls. Use a VL
variant when your agent receives screenshots or images in AgentMessage.images.
MlxChatModel¶
MlxChatModel wraps a loaded ModelContainer into a ChatModel:
public struct MlxChatModel: ChatModel {
public init(
container: ModelContainer,
supportsVision: Bool,
...,
generateParameters: GenerateParameters
)
public func makeSession() -> any ModelTurnSession // returns RebuildTurnSession
}
makeSession() returns a RebuildTurnSession, which rebuilds the full prompt from the message
history on every call. This is intentional - it lets middleware rewrite the history freely between
rounds, and the chat template re-renders the exchange faithfully (dropping historical <think>
blocks). Prompt processing is still incremental: the session reuses the KV/SSM state of the
unchanged token prefix (see below), so rebuilding does not mean re-prefilling.
RebuildTurnSession¶
Implements ModelTurnSession. On each nextTurn call it serialises the full [AgentMessage]
conversation via the model's codec, runs the MLX generate loop, and streams chunks back via
onChunk. Tool calls are extracted per codec family (see below).
Prefix caching¶
The system prompt and tool schemas dominate the prompt (often ~10k tokens) and are identical
across a run's rounds - and across runs on the same model + configuration. RebuildTurnSession
reuses their computed KV/SSM state through a PrefixCacheSlot so each round prefills only the
tokens that changed:
public final class PrefixCacheSlot { public init() }
public struct MlxChatModel: ChatModel {
public init(..., prefixCache: PrefixCacheSlot? = nil)
}
- With
prefixCache: nil, reuse is within-run: rounds 2+ of a ReAct loop prefill only the new tool results instead of the whole prompt. - Passing a shared per-model
PrefixCacheSlotextends reuse across queries: a new conversation on a resident model reuses the system+tools prefix.MlxModelLoaderdoes this automatically and drops the slot onunload(_:)(the state is tied to the resident container).
Fully-trimmable caches are rewound with KVCache.trim. The LFM2.5 and qwen3_5 families are
attention + Mamba/conv hybrids whose recurrent layer state cannot be rewound - and Gemma 4's
sliding-window layers use a RotatingKVCache that claims trim success even after the window has
rotated the tokens away - so the session keeps copy()-snapshots at two boundaries instead: a
tip just before the end of the latest prompt (next round resumes there) and a base at the
first observed cross-conversation divergence - in practice the system+tools boundary (a new query
resumes there). Reuse is keyed on the longest
common token prefix, so a middleware rewrite, a dropped <think> block, or a different user
turn simply falls after the reused prefix and is re-prefilled; image turns bypass caching entirely
(image embeddings are not represented in the text tokens). In practice this takes a multi-round
turn's time-to-first-token from ~30 s to under a second on a 9B model with a ~10k-token prompt.
Disk persistence¶
The base snapshot also survives across processes. After a turn, the session persists it under
~/.cache/deepagents/prefix-kv/ (via mlx-swift-lm's prompt-cache serialization, which round-trips
the hybrid models' recurrent MambaCache too); a fresh process resumes from it on its very first
turn and prefills only the conversation suffix - so a one-shot CLI run or an app relaunch skips the
multi-second prompt prefill entirely.
Base snapshots are content-addressed: keyed by model id + a hash of the exact tokens they cover, not by configuration. On a cold start the store picks the longest stored base whose tokens are a strict prefix of the incoming prompt - whichever configuration wrote it. Configs that share a prompt prefix therefore share snapshots: if an MCP server is down or a tool set changed, the run still resumes from the shared part of the prompt and re-prefills only what differs, then deepens the snapshot to its own stable boundary so its next run resumes past the shared part too. Every base is validated before use: the metadata must match the model id and its downloaded revision (so re-downloaded weights can't replay stale KV), and the snapshot's tokens must be a strict prefix of the incoming prompt (the same guarantee as the in-memory snapshots - a template change such as a new date simply misses and re-warms).
Alongside the bases, a small token trace of each configuration's last prompt is persisted after
every turn. Diffing the incoming prompt against the best trace on record is how a fresh process
learns where the stable prefix ends - to establish a base on a config's first sighting, or to
deepen a shared one. Hosts can switch the store off at runtime via
PrefixKVStore.isEnabledOverride (ripple exposes this as the Prefill cache toggle in
/config, persisted as prefixKVCache in settings.json); the DEEPAGENTS_PREFIX_KV=0 env kill
switch and DEEPAGENTS_PREFIX_KV_DIR relocation also apply. Corrupt or stale files are dropped
silently (they are an optimization, never a dependency).
Bounding the store¶
Two limits, because one cannot do the job alone. PrefixKVStore.maxSnapshotsPerModel (default 6)
keeps each model's newest N bases, so a model used constantly cannot evict the warm base of one
used rarely - the previous global count did exactly that, leaving both cold when a host alternated
between two models. PrefixKVStore.maxTotalBytes (default 4 GB, 0 for no cap) then bounds the
directory as a whole, because per-model counts say nothing about bytes: one 2.6B base measures
around 260 MB. Pruning applies them in that order and never evicts the newest snapshot, since a cap
smaller than a single base would otherwise mean writing and deleting it on every turn. Traces are
bounded separately at 16 files of tens of KB and are not counted against the byte cap.
Showing and reclaiming the space¶
PrefixKVStore.inventory(directory:knownModelIDs:) returns what the store holds, grouped by model
with per-model and total byte counts, for a host that wants to show disk use.
PrefixKVStore.removeAll(modelID:) and removeAll() delete a model's artifacts or the whole
store, and pruneNow() applies the current limits immediately rather than at the next save.
These three deliberately ignore the enabled flag: switching the cache off stops it writing, and
must not strand the hundreds of MB already on disk. The scan is read-only and reads safetensors
headers only, never tensor data. Artifacts are attributed to a model by the model key in that
header (and, for traces, in the payload) - never by parsing the file name, which flattens / to
-- and can leave one model id prefixing another.
MlxModelLoader¶
MlxModelLoader reads models from the local Hugging Face cache at ~/.cache/huggingface/hub.
Pre-fetch a model with hf download <id>; the loader serves it from the cache. The high-level
entry point turns a Hugging Face model id straight into a ready MlxChatModel:
// Typical usage - high-level loader
let loader = MlxModelLoader()
guard let model = await loader.loadChatModel("LiquidAI/LFM2.5-1.2B-Instruct-MLX-bf16") else {
fatalError("model not in the local Hugging Face cache - run `hf download` first")
}
loadChatModel(_:) returns nil when the model is not available locally, and applies the
catalog's recommended sampling parameters for that id. For finer control, the static
MlxModelLoader.loadContainer(...) returns a ModelContainer you can wrap with the
MlxChatModel(container:supportsVision:...:generateParameters:) initializer shown above.
Two observable properties help a UI narrate loads: lastLoadError records why the most recent
load returned nil, and loadingModelID is the id currently cold-loading from disk (nil
otherwise) - so a lazy reload can be labeled as a model load instead of reading as slow prompt
processing.
Pass model directly to createAgent or createDeepAgent.
LFM2-specific tool-call parsing¶
LFM2.5 models emit tool calls inside a custom span:
DeepAgentsMLX suppresses mlx-swift-lm's built-in Pythonic tool-call parser, which truncates
list and dict argument values at the first comma. The framework's own parser (LFM2ToolCalls,
LFM2ToolCallStream) consumes the full <|tool_call_start|>...<|tool_call_end|> span and
correctly preserves nested arrays and objects in argument values.
Reasoning content (<think>...</think> blocks emitted by reasoning models) is split out by the
shared ThinkStream and surfaced as AgentContentBlock.reasoning - it does not appear in the tool
arguments or the text content block.
qwen3_5 family: Ornith and Qwen3.6¶
DeepAgentsMLX drives more than one wire format. Each catalog model declares a codecFamily
(MlxModel.codecFamily), and RebuildTurnSession selects the matching codec per turn - a new wire
format is a new codec, not a change to the agent loop.
The qwen35 codec drives Ornith-1.0-9B
(model_type: qwen3_5) and the Qwen3.6 models
(qwen3_5 / qwen3_5_moe). Unlike LFM2, their <tool_call> blocks hold Qwen XML
(<function=name><parameter=k>value</parameter></function>), which mlx-swift-lm's own
XMLFunctionParser parses correctly - so the adapter does not suppress the built-in parser for
this family; it consumes the resulting Generation.toolCall events and only splits <think>
reasoning in-app (Qwen35Decoder).
Ornith is a unified vision-language model used as a reasoning planner: cataloged kind: .language
(it lists in the planner picker and drives the tool loop) with acceptsImages: true. The
acceptsImages capability - distinct from the kind-based isVision - makes loadsAsVision
true, so it loads through the VLM factory and can also back the vision subagent. One repo, both
roles.
The Qwen3.6 OptiQ conversions are text-only planners by contrast: those repos ship no
processor configs (images could not be prepared), and their sidecar weight files
(mtp.safetensors, optiq_vision.safetensors) are only tolerated by the LLM path's weight
sanitize - the VLM path fails with Unhandled keys ["mtp"]. They therefore load through the text
factory and appear only in the planner picker.
| Model | approx. size | Notes |
|---|---|---|
| Ornith 1.0 9B (4-bit / 8-bit) | ~5.2 / ~9.6 GB | temp 0.6 · top-p 0.95 · top-k 20; vision-capable |
| Qwen3.6 27B (OptiQ 4-bit) | ~20 GB | temp 1.0 · top-p 0.95 · top-k 20; text-only |
| Qwen3.6 35B A3B (OptiQ 4-bit, MoE) | ~24.7 GB | as 27B + presence penalty 1.5 (per card) |
The qwen3_5 family reports the 262k window its cards document. Keeping a session inside what the
machine can hold is SummarizationConfig.triggerFraction's job (80% by default), not a smaller
declared window - shrinking the number here made the context meter describe a model that does not
exist. The Qwen3.6 OptiQ quants carry per-layer bit overrides, handled by mlx-swift-lm's
perLayerQuantization.
gemma4 family: Gemma 4 E4B¶
The gemma4 codec drives Gemma 4 E4B
(model_type: gemma4, 4.5B effective / 8B raw parameters, hybrid sliding-window + global
attention). Like the qwen family, tool calls are parsed by mlx-swift-lm itself: the model emits
<|tool_call>call:name{...}<tool_call|> spans that the library's GemmaFunctionParser strips
and surfaces as Generation.toolCall events, so the adapter keeps the built-in parser active and
only splits the reasoning channel in-app (Gemma4Decoder).
Reasoning is opt-in via the chat template: the adapter passes enable_thinking so the template
opens the system turn with <|think|>, and the model then wraps its reasoning in a
<|channel>thought ... <channel|> block that the decoder routes to
AgentContentBlock.reasoning. Unlike Ornith, the template prefills no opener - the stream starts
in the answer and the model emits the full marker pair itself.
Gemma 4's chat template also reads two shapes the other families ignore: tool responses are
matched to their calls by tool_call_id, and recent assistant turns re-render their reasoning -
so the gemma encode includes both (LFM2MessageCodec.renderMessages extras that stay off for
the other families).
| Model | approx. size | Notes |
|---|---|---|
| Gemma 4 E4B (8-bit) | ~9 GB | temp 1.0 · top-p 0.95 · top-k 64; text-only for now (see below) |
| Gemma 4 E4B (OptiQ 4-bit) | ~7.5 GB | mixed-precision per-layer bits; text-only |
Both rows load through the text (LLM) factory and are planner-only for now. The OptiQ
conversion is text-only by construction: it ships no processor configs and carries an
optiq_vision.safetensors sidecar outside the weight index, so it goes behind the
sidecar-filtered snapshot view (the same treatment as the Qwen3.6 OptiQ quants). The 8-bit
conversion does ship full processor configs, but mlx-swift-lm 3.31.4's MLXVLM Gemma4 path
cannot load E-series checkpoints - its backbone builds K/V projections for every layer while its
sanitize drops them for the num_kv_shared_layers tail, so weight verification always fails
(upstream issue #338, fix pending in #384). Once that fix ships, the 8-bit row flips to
acceptsImages: true for Ornith-style dual-role duty. Both report the 128k window their card
documents.
A follow-up will wire mlx-community/gemma-4-E4B-it-assistant-bf16 - Google's 78.8M
multi-token-prediction drafter for Gemma 4 - as a speculative-decoding accelerator (mlx-swift-lm
already ships the Gemma 4 MTP machinery); it is not a standalone chat model and is not cataloged.
Example¶
import DeepAgents
import DeepAgentsMLX
// 1. Load a model from the local Hugging Face cache by its id
let loader = MlxModelLoader()
guard let model = await loader.loadChatModel("LiquidAI/LFM2.5-1.2B-Instruct-MLX-bf16") else {
fatalError("model not in the local Hugging Face cache - run `hf download` first")
}
// 2. Create an agent
let agent = createDeepAgent(
model: model,
systemPrompt: "You are a helpful assistant.",
includeFilesystem: false,
includeGeneralPurpose: true,
maxIterations: 12
)
// 3. Run
let ok = await agent.run([.human("Summarise the top-level files in this repo.")]) { event in
// handle AgentEvent
}