← the journey
03

stop 03 · published

Tool assembly

architecture risk ●●●●
source anchors · @0fb18e3

Most readers meet the tools module and think: “this is where functions get registered.” That is only half true. Its real shape appears the day a tool you clearly configured does not appear in front of the model, and you finally ask the useful question: who decides whether a tool is visible?

Hold onto this: this stop is not a function registry; it is a visibility calculation for one run. Config, built-ins, model capability, sandbox rules, skills, MCP, and subagent policy all decide which tools are added, removed, or deferred. Only after that policy pass do you get the final list handed to create_agent(..., tools=...).

flowchart TD
SRC["config.yaml · built-ins · MCP · ACP"] --> GA["get_available_tools()"]
GA --> G1["tool_groups filter"]
G1 --> G2["host bash safety gate"]
G2 --> G3["model capability · vision"]
G3 --> DD["dedupe by tool.name"]
DD --> SK["skill allowed_tools filter"]
SK --> DF["assemble_deferred_tools · defer MCP"]
DF --> CA["create_agent(tools=final_tools)"]
From tool sources to what this run is allowed to see: a two-stage assembly

A candidate set, not the final set

The main entry is get_available_tools() in tools.py . But note the boundary: what it produces is not the final tool list. It is a candidate set. It answers a deliberately limited question:

Given system config, built-ins, and runtime switches,
which tools can this agent candidate acquire?

The flow is roughly: start from AppConfig.tools → filter by a custom agent’s tool_groups → drop host bash if it is not allowed → dynamically import via resolve_variable(cfg.use, BaseTool) → add built-ins → add task_tool by switch and view_image_tool by model capability → attach MCP / ACP → and finally dedupe by tool.name.

Then, back in _make_lead_agent() ( agent.py ), the candidate set passes through two more gates: the skill-level allowed_tools filter and the MCP deferred-tool assembly. Visibility is two-stage: gather candidates, then apply policy.

Config is not the tool itself

A tool in config.yaml looks like this:

tools:
  - name: read_file
    group: file:read
    use: deerflow.sandbox.tools:read_file_tool

That use field is not Python syntax. It is a DeerFlow convention: module.path:variable_name. resolve_variable() hands the part before the colon to import_module(), pulls the variable after the colon from that module, and checks that it really is a BaseTool instance.

This produces a key distinction worth remembering:

the name in config  =  a label for humans reading config
tool.name           =  the name actually exposed to the model and used for routing

Routing looks at BaseTool.name, which need not equal the name written in config.yaml. The moment they diverge, the model sees one schema name while the runtime routes by another — and DeerFlow logs a warning about exactly that.

Visibility is several gates, not one whitelist

“Why is this tool missing?” is hard to chase because visibility crosses several gates, each with its key in a different file:

A tool is not one shape

The second trap in reading the tools module is assuming every tool is “just a function call.” DeerFlow tools come in at least four shapes — recognize them and several later stops get easier:

The tool exists; its schema may not be exposed

MCP tools are the clearest proof that a tool can sit in the list and still be invisible to the model. With tool_search.enabled on, MCP tools do not dump full schemas onto the model at once. The prompt lists only names, and the model calls tool_search for the schema when it needs one. The reason is pragmatic:

A single MCP server can expose dozens of tools.
Binding every schema = bigger prompt, higher cost, noisier tool selection.

This path crosses several layers — a textbook case of DeerFlow spreading one feature across modules ( tool_search.py ):

get_available_tools  ->  tag_mcp_tool(t)
assemble_deferred_tools  ->  build catalog + tool_search, return DeferredToolSetup
apply_prompt_template  ->  prompt lists deferred tool names only
model calls tool_search  ->  returns matching schemas + writes state.promoted
DeferredToolFilterMiddleware  ->  unpromoted schemas hidden; a raw call returns an error

The reducer for ThreadState.promoted scopes by catalog_hash: same hash unions the promoted names, a changed hash replaces them — so a stale same-named promotion can’t release a different tool after MCP config changes.

What dynamic assembly buys

  • pluggable sources: config / built-ins / MCP / ACP, all alike
  • visibility computed per run — tunable by model, sandbox, skill
  • deferral keeps a flood of MCP tools from bursting the prompt

The cost

  • dynamic import fails late (only at runtime)
  • one feature spread across metadata/assembly/prompt/state/middleware
  • tool.name may diverge from the config name

Where it will trip you up


The tool-assembly stop answers “which tools is this run allowed to see or call,” not “how a given tool runs.” Keep those questions apart and the whole tool system becomes easier to follow. Once the tools are gathered and the graph is assembled, the next behavior-setting variable appears: middleware order. Next stop, we watch order turn into runtime semantics.