Middleware¶
Middleware is DeepAgents' primary extension point. Every middleware observes and can rewrite the full agent lifecycle - messages, system prompts, tool lists, and individual tool calls. The same mechanism powers DeepAgents' built-in structural pillars (planning, filesystem, subagents, summarization, human-in-the-loop) and the swappable capability toolsets (web, git, shell, macOS, ...).
AgentMiddleware protocol¶
public protocol AgentMiddleware: Sendable {
var name: String { get }
var tools: [any AgentTool] { get }
func beforeAgent(_ state: inout AgentState) async
func beforeModel(_ state: inout AgentState) async
func afterModel(_ state: inout AgentState) async
func afterAgent(_ state: inout AgentState) async
func wrapModelCall(
_ request: ModelRequest,
_ handler: (ModelRequest) async throws -> ModelResponse
) async throws -> ModelResponse
func wrapToolCall(
_ request: ToolCallRequest,
_ handler: (ToolCallRequest) async throws -> AgentMessage
) async throws -> AgentMessage
}
Hook execution order¶
run(...) called
│
├── beforeAgent (all middleware, once per run, in registration order)
│
│ ┌── [Round N] ───────────────────────────────────────────────────┐
│ │ beforeModel (all middleware, every round, registration order) │
│ │ │
│ │ wrapModelCall ← NESTED, first-registered is OUTERMOST │
│ │ └── session.nextTurn(...) │
│ │ /wrapModelCall │
│ │ │
│ │ afterModel (all middleware, every round, registration order) │
│ │ │
│ │ For each tool call: │
│ │ wrapToolCall ← NESTED, first-registered is OUTERMOST │
│ │ └── tool.execute(...) │
│ │ /wrapToolCall │
│ └── [End round] ────────────────────────────────────────────────┘
│
└── afterAgent (all middleware, once per run, reverse order)
Nesting vs. sequencing
wrapModelCall and wrapToolCall are nested decorators - each middleware wraps the next, exactly like HTTP middleware stacks. The middleware registered first is the outermost layer: it can inspect and modify the request before any inner middleware sees it, and it sees the final response after all inner middleware have processed it. By contrast, beforeModel/afterModel are sequential callbacks that each mutate shared AgentState.
What each hook can do¶
| Hook | When | What you can do |
|---|---|---|
beforeAgent |
Once at run start | Inject system prompt additions, initialise state, log run start |
beforeModel |
Before each model call | Rewrite messages, edit systemPrompt, add/remove tools |
afterModel |
After each model call | Inspect the just-produced assistant message, update state |
afterAgent |
Once at run end | Flush logs, release resources, report metrics |
wrapModelCall |
Around the model call | Intercept/retry the model call; rewrite request (messages, tools, prompt) |
wrapToolCall |
Around each tool call | Intercept/approve/deny/retry a tool call; rewrite arguments or result |
AgentState holds the live conversation thread, system prompt, and tool list for the current round. Mutations in beforeModel are visible to the model call that follows.
Contributing tools¶
The tools property lets middleware own the tools it provides. Tools contributed this way are merged with the explicit tools: array at factory time, then filtered through disabledToolNames. Middleware should declare all the tools it manages via this property rather than injecting them through beforeModel - this ensures correct deduplication and policy enforcement.
If the middleware also appends prompt guidance describing those tools, gate it on contributesRenderedTools(to:) - with lazy tools on, a tool's schema can be withheld from the request while staying callable, and prose about a tool the model cannot see does active harm.
Structural pillars¶
Structural middleware implements the core agent architecture. createDeepAgent wires these in automatically; createAgent leaves them out unless you add them yourself.
TodoListMiddleware¶
Adds planning discipline to the agent. Appends writing guidance to the system prompt and contributes the write_todos tool, which lets the model maintain a structured task list that persists across rounds within a run.
FilesystemMiddleware¶
Provides file I/O through a pluggable FilesystemBackend. Two backends ship:
StateBackend- in-memory; good for tests and isolated runsLocalFilesystemBackend- reads and writes real files on disk
Tools: ls, read_file, write_file, edit_file, mkdir. Pass a backend via createDeepAgent's backend: parameter; it defaults to StateBackend() when includeFilesystem: true.
With LocalFilesystemBackend, createDeepAgent also names the root in the system prompt ("Your working folder is …", plus a note that paths outside it are refused). Without it a model has nothing to go on and invents a path: across 47 on-device runs, 30 produced outside the allowed folder errors - 148 refused calls - including a model on macOS reaching for /home/user, and one reaching for a directory named after the project rather than the checkout it was running in. StateBackend names nothing, having no path a tool call could get wrong.
SubAgentMiddleware¶
Enables task delegation. Contributes the task tool; when the model calls task, the middleware routes execution to one of the registered SubAgent instances (or to a general-purpose sub-agent when includeGeneralPurpose: true). See Subagents.
SummarizationMiddleware¶
Hooks beforeModel and compacts the conversation when the context window reaches 80% of capacity. Summarised segments are replaced with a single synthetic message whose source field is set to identify it as compaction-synthesised. Configured via SummarizationConfig (pass nil to disable). See Summarization.
HumanInTheLoopMiddleware¶
Hooks wrapToolCall and calls your ToolApprovalHandler before every tool execution. The handler can approve, deny, or ask for user confirmation. Required for tools with side effects (file writes, shell commands, macOS automation). See Human in the loop.
AskUserMiddleware¶
Contributes the ask_user tool. When the model calls ask_user, execution suspends until your AskUserHandler returns a string. This lets the model request clarification mid-run without ending the run.
Capability catalog¶
Capability middleware provides toolsets that map cleanly to a single concern. All of these live in MiddlewareCatalog.all and can be enabled by ID. createDeepAgent with includeGeneralPurpose: true adds web, search, text, git, and shell automatically.
| Middleware ID | Type | Tools contributed |
|---|---|---|
web |
WebToolsMiddleware |
fetch, curl |
search |
SearchToolsMiddleware |
grep, glob, tree |
text |
TextToolsMiddleware |
head, tail, diff |
git |
GitToolsMiddleware |
git_status, git_diff, git_log, git_show, git_blame |
shell |
ShellToolsMiddleware |
shell (gated by ShellGuard) |
macos |
MacToolsMiddleware |
mdfind, open, open_app, download, say, notify |
filesystem |
FilesystemMiddleware |
ls, read_file, write_file, edit_file, mkdir |
clipboard |
ClipboardMiddleware |
read_clipboard, write_clipboard |
screenshot |
ScreenshotMiddleware |
take_screenshot, take_window_screenshots |
apple_notes |
AppleNotesMiddleware |
list_notes, read_note, create_note, update_note |
container |
ContainerShellMiddleware |
container_shell (sandbox mode) |
macOS adapter required
screenshot, clipboard, apple_notes, macos, and container are provided by the DeepAgentsMacTools product and require macOS entitlements. Import DeepAgentsMacTools separately.
ToolSearchMiddleware - lazy tool loading¶
Every tool's JSON schema normally goes into the prompt on every round. ToolSearchMiddleware splits the tool set into two tiers so that most of it does not:
- Core tools are rendered as usual.
- Auxiliary tools are stripped from
ModelRequest.toolsinwrapModelCall, so they never reach the prompt - but they stay inReactAgent.tools, andReactAgentdispatches against that. They remain fully callable: invisible, but executable.
The agent finds them with the two meta-tools the middleware contributes:
| Tool | Purpose |
|---|---|
search_tools |
Rank auxiliary tools against a description of what is needed; returns names and signatures as its tool result. |
run_tool |
Call an auxiliary tool by name, for a model that will not emit a name absent from its own schema. |
Enable it through createDeepAgent:
let agent = createDeepAgent(
model: model,
middleware: [GitToolsMiddleware(root: root), WebToolsMiddleware()],
auxiliaryToolNames: policy.expand().auxiliaryToolNames,
toolRetriever: ColBERTToolRetriever(), // or omit for LexicalToolRetriever
toolSearchLimit: 5
)
Why the schemas arrive as a tool result¶
MlxChatModel caches the KV of the prompt's stable prefix and fingerprints it on systemPrompt + toolNames, so growing the tool list mid-run would reset that cache and force a re-prefill - the obvious implementation of lazy tools is the one thing that cannot be done cheaply. So the rendered set never changes: it is a constant filter, and everything discovery produces arrives as conversation content, which appends past the cached tip. Editing a tier costs one re-prefill; discovering a tool costs none.
ReactAgent.renderedTools (via the ToolRenderFiltering protocol) is what the prompt-overhead estimate measures, so summarization is not triggered early by schemas the model never sees.
run_tool is rewritten into a direct call inside wrapModelCall, before ReactAgent normalizes, records, or dispatches it - which is what keeps the approval gate, the message log, and the transcript pointed at the real tool.
The cost of that choice: a catalogue in the data slot¶
Delivering schemas as a tool result has one failure mode worth knowing about, because it is created by the mechanism rather than inherited from the model.
A tool result is where every real result the model has ever seen arrived. A search_tools result is not data - it is a description of what data would look like, and descriptions are written in the future tense ("Returns the note's title and its plain-text body"). A small planner collapses that into the past tense. Observed on a 1.2B: handed read_note(title!: string, …) by a search, it called nothing and answered with a note title, a note id (A1B2C3D4) and a full note body - every character invented, all of it presented as the user's real data. Unlike a wrong-tool call, this produces no error and no failed call, so it reads as success.
Two countermeasures ship, and both are about making the failure loud rather than preventing it outright:
- The result names its own genre: it opens "these are tool definitions you may now call, not results. Nothing has been read or run yet", and ends by saying that describing what a tool would return is not an answer.
ToolSearchMiddleware.wrapModelCallappends one follow-through turn whenever the conversation ends on a search result, naming the tools that were offered and repeating that nothing has run. It is transient - it goes into that oneModelRequest, never intostate.messages, so it cannot reach the stored thread - and it sits at the end of the conversation because that is immediately before the generation point. It leaves an honest way out ("if none of them fits, say plainly that you could not do it"), so a search that genuinely found nothing never turns into a forced call.
Neither makes a weak planner competent. If you host this, prefer a mid-size planner or better, and treat a confident answer with no preceding tool call as suspect.
The structural alternative - promoting a discovered tool into the rendered set, where a chat template marks schemas with its own tokens and the model cannot mistake them for content - costs one re-prefill per session rather than none. It is recorded as the long-term option in STEPS/TOOL-SEARCH/DESIGN.md and deliberately not taken yet.
Withholding a schema withholds the prose¶
If you write middleware that contributes both tools and prompt guidance, gate the guidance. Stripping ModelRequest.tools does nothing to the prose a middleware appends in its own wrapModelCall, and a section that names a tool whose schema was withheld is worse than no section: the agent is told to call something it has no way to call, and reaches for an unrelated tool instead.
AgentMiddleware provides the check:
public func wrapModelCall(
_ request: ModelRequest, _ handler: ...
) async throws -> ModelResponse {
// Say nothing about tools that did not reach this request.
guard contributesRenderedTools(to: request) else { return try await handler(request) }
let composed = [request.systemPrompt, Self.systemPrompt]
.compactMap { $0 }
.joined(separator: "\n\n")
return try await handler(request.override(systemPrompt: composed))
}
contributesRenderedTools(to:) is true when at least one of the middleware's own tools appears in request.tools. Middleware that contributes no tools keeps its guidance unconditionally, so the default is safe. All the built-in capability middleware use it.
Two things this rules out, both of which were live bugs:
- Naming a tool in prose that the tier withheld. With
apple_notesauxiliary, the prompt still read "you can read and write the user's Apple Notes - never claim you can't" while no such schema was rendered. - Naming an auxiliary tool in guidance that is otherwise tier-agnostic. Prose meant to steer the model away from something should describe the activity, not the tool - a bullet naming
ls/read_fileputs those names back in the prompt in exactly the configurations where the filesystem is itself auxiliary.
Retrievers¶
ToolRetriever ranks the auxiliary corpus. Two ship:
| Type | Product | Notes |
|---|---|---|
LexicalToolRetriever |
DeepAgents |
IDF-weighted term overlap. No model, no download - the default. |
ColBERTToolRetriever |
DeepAgentsMLX |
LFM2.5-ColBERT-350M late interaction (MaxSim over per-token 128-d vectors), 8-bit or bf16 via ToolSearchModel. |
createDeepAgent composition order¶
When you call createDeepAgent, middleware is assembled in this order before being handed to ReactAgent:
ToolSearchMiddleware(whenauxiliaryToolNamesis non-empty)SummarizationMiddleware(whensummarization != nil)TodoListMiddlewareFilesystemMiddleware(whenincludeFilesystem: true)SubAgentMiddleware- Your
middlewarearray (in the order you provide) AskUserMiddleware(whenaskUserHandler != nil)HumanInTheLoopMiddleware(whenapprovalHandler != nil)
Both wrapModelCall and wrapToolCall nest with the first-registered middleware outermost, which is what fixes the two ends of this list:
ToolSearchMiddlewareis registered first on purpose, so itswrapModelCallis the outermost one and the auxiliary schemas are already stripped before anything that might describe them runs. Every capability middleware decides whether to append its prompt guidance by inspectingrequest.tools(seecontributesRenderedTools), and that check is only meaningful once the stripping has happened.HumanInTheLoopMiddlewareis registered last, so forwrapToolCallit is the innermost wrapper - the last layer before the tool actually executes. That is the stronger position for a gate: no other middleware can execute the call without passing through it.
SummarizationMiddleware hooks beforeModel rather than nesting, and is registered ahead of the other hooks so it compacts the history before they read it.
Disabling middleware and tools¶
Via disabledToolNames¶
Both factories accept disabledToolNames: Set<String>. Any tool whose name appears in this set is removed from the merged tool list at factory time. The model never sees the tool in its schema - it cannot call something that was never offered.
Via AgentToolPolicy¶
public struct AgentToolPolicy: Codable, Sendable {
public var disabledMiddleware: Set<String> // middleware IDs (catalog names)
public var disabledTools: Set<String> // individual tool names
public var approvals: [String: ToolApprovalMode]
public var sandbox: SandboxMode
public var sandboxImage: String?
// Lazy tools - see `ToolSearchMiddleware`. Inert while `toolSearch` is false.
public var toolSearch: Bool
public var auxiliaryMiddleware: Set<String> // middleware IDs whose tools are auxiliary
public var auxiliaryTools: Set<String> // individual auxiliary tool names
public var coreMCPServers: Set<String> // servers promoted to core (MCP defaults to auxiliary)
public var toolSearchModel: String? // retriever repo id; nil = lexical
public var toolSearchLimit: Int
public func expand(
catalog: [MiddlewareDescriptor] = MiddlewareCatalog.all,
extraDefaults: [String: ToolApprovalMode] = [:],
extraAuxiliary: Set<String> = []
) -> Expansion
}
AgentToolPolicy is a serialisable value (Codable) useful for per-user or per-session configuration - for example, persisting the user's approval preferences between sessions in Ripple. Call expand(...) to resolve the policy against the live catalog and get back a concrete set of tools to disable and approval modes to apply.
Disabling is at factory time
Tools removed via disabledMiddleware or disabledTools are never rendered into the model's prompt. This is architecturally different from the approval gate, which fires at dispatch time. Disabled tools cost zero tokens and cannot be called even accidentally; approval-gated tools appear in the prompt but require explicit authorisation before execution.
Related pages¶
- Tools & policy -
AgentToolprotocol,ToolParameter,AgentToolPolicyin detail - Subagents -
SubAgentMiddlewareand thetasktool - Human in the loop -
HumanInTheLoopMiddlewareand approval handlers - Summarization -
SummarizationMiddlewareandSummarizationConfig - Write custom middleware - implementing
AgentMiddlewareyourself