backend/packages/harness/deerflow/tools/builtins/task_tool.py @7e7f041 backend/packages/harness/deerflow/subagents/executor.py @7e7f041 backend/packages/harness/deerflow/subagents/registry.py @7e7f041 backend/packages/harness/deerflow/subagents/config.py @7e7f041 backend/packages/harness/deerflow/subagents/builtins/general_purpose.py @7e7f041 backend/packages/harness/deerflow/subagents/builtins/bash_agent.py @7e7f041 backend/packages/harness/deerflow/agents/middlewares/subagent_limit_middleware.py @7e7f041 backend/packages/harness/deerflow/skills/tool_policy.py @7e7f041 A lead agent often meets a subtask that would consume too many steps or too much context: scan the whole repository, inspect many files, run commands, and return a distilled finding. DeerFlow exposes that pattern through the task tool. From the lead agent’s point of view it is a normal tool call. Behind that tool call, DeerFlow creates another full agent and runs it in the background.
The key idea:
A subagent is another full agent created by the same
create_agentfactory. It inherits the parent’s work context, but receives a narrower tool surface, a shorter lifecycle, and a controlled result path so it can handle complex work without polluting the lead agent’s loop.
flowchart TD LEAD["1. lead agent: create_agent first call"] --> AM["2. after_model: SubagentLimit trims to <= 3"] AM --> TN["3. tools node executes task_tool"] TN --> TT["4. task_tool builds executor, polls, wraps result"] TT --> SUB["5. create_agent second call creates subagent"] SUB -.->|"extract final text"| BACK["task_tool returns string -> ToolNode wraps ToolMessage"]
Three Layers
The system is easiest to read if you keep three layers separate:
1. lead agent
created by create_agent
has a tools node that includes the task tool
2. task_tool
a normal async @tool function
starts background execution, polls status, streams progress, returns a string
3. subagent
another graph created by create_agent
runs its own agent loop
create_agent is called twice in this path: once to create the lead agent, and once to create the subagent. task_tool is not an agent. It is the adapter that turns one tool call into a background agent execution.
Admission Control
Before the tool path creates subagents, SubagentLimitMiddleware (see subagent_limit_middleware.py::SubagentLimitMiddleware ) gets a chance to trim the model’s tool calls.
It runs in after_model, after the model has produced an AIMessage but before the tools node executes. If the model emits more than MAX_CONCURRENT_SUBAGENTS task calls, DeerFlow keeps only the allowed number.
SubagentLimitMiddleware.after_model decides how many task calls may proceed
task_tool body creates and schedules the subagent
The default cap is 3. That does not mean DeerFlow has an independent task planner. The model and prompt still decide how to split work; the middleware only prevents too many subagents from being launched at once.
Inherit And Sever
The design tension is simple: the subagent must inherit enough context to be useful, but lose enough capability to stay bounded.
Inherited context Severed capability
------------------------------------ ------------------------------------
sandbox same execution env no task tool: subagent_enabled=False
thread_data same file context checkpointer=False: no resume/archive
thread_id same conversation id fresh state per task
The inherited side starts in task_tool.py::task_tool . task_tool reads sandbox, thread_data, and thread_id from the parent runtime, then passes them to SubagentExecutor. The executor places them into the subagent’s initial state and run config. This is why a subagent can read the same workspace and execute in the same sandbox as the lead.
The severed side has three parts:
No task tool
task_tool calls get_available_tools(..., subagent_enabled=False).
This prevents recursive delegation.
checkpointer=False
The subagent is a one-shot execution unit.
It does not participate in the lead agent's resume path.
Fresh state
Each task gets its own messages and promoted-tool state.
Parallel subagents do not share their internal conversation histories.
Why ThreadState Is Reused
Subagents use the same ThreadState schema as the lead agent. That is not for returning a ToolMessage to the parent. It is because the subagent itself is a full agent that may run sandbox tools.
subagent tools need state["sandbox"] and state["thread_data"]
ThreadState declares those fields
therefore the subagent can reuse ThreadState
Schema is the set of slots; inheritance is how values are placed into those slots. The result path is separate and appears later.
Tool Permissions
The subagent’s tool set is built through three filters. Each one narrows the set; none of them expands authority.
parent tool scope
-> filter 1: inherit parent tool_groups and remove task via subagent_enabled=False
candidate tools
-> filter 2: subagent config allow/deny
role-specific tools
-> filter 3: skill allowed-tools policy
final tools
-> then append deferred tool_search over the already-filtered catalog
The deferred tool_search tool is intentionally assembled after policy filtering (see executor.py::SubagentExecutor._build_initial_state ). Its catalog is built from the filtered tools, so it cannot reveal a tool that the policy denied.
Skills
Skills are handled in two phases: permission first, content loading second.
The permission merge in task_tool.py::_merge_skill_allowlists keeps the child inside the parent’s allowed set:
parent is None use the subagent's config.skills
child is None inherit the parent's whole allowlist
both are present use intersection: child requested AND parent allowed
Loading in executor.py::SubagentExecutor._load_skills has three different meanings:
config.skills = None load all enabled skills
config.skills = [] load no skills
config.skills = ["a", "b"] load only those enabled skills
Each skill is first represented as a temporary SystemMessage, but those messages are not inserted as separate conversation messages. _build_initial_state joins the configured system prompt, skill contents, and deferred tool note into one final SystemMessage. This avoids APIs that reject multiple system messages.
state["messages"] = [
SystemMessage(system_prompt + skills + deferred-tool note),
HumanMessage(task),
]
The tool schemas themselves are not in messages. They are bound to the model through the tools parameter passed to create_agent. Deferred tool names appear in the prompt only as a lightweight note; full schemas remain hidden until promotion.
Configuration Sources
get_subagent_config(subagent_type) resolves a SubagentConfig from two main sources:
built-in types: general-purpose / bash
defined in Python under subagents/builtins
system_prompt is a source-code string
custom agents
defined in config.yaml under subagents.custom_agents
system_prompt comes from config
per-agent overrides
config.yaml subagents.agents
may override timeout_seconds, max_turns, model, skills
does not override built-in system_prompt
So the built-in subagent prompts are intentionally stable. To change one, either edit source or define a custom agent type.
The built-in general-purpose subagent defaults to max_turns=150, while bash has its own built-in cap. A global subagents.max_turns setting overrides those built-in values. SubagentConfig.timeout_seconds=900 is the bare fallback; for built-ins the effective timeout usually comes from global subagents.timeout_seconds (default 1800 seconds).
Background Execution
The runtime shape is more complex than the concept:
task_tool coroutine
stays on the parent event loop and polls every 5 seconds
execute_async
submits work to _scheduler_pool, returns task_id immediately
subagent coroutine
runs on a long-lived isolated event loop in a daemon thread
The shared state table is _background_tasks[task_id] -> SubagentResult. The background side writes status, AI message snapshots, token usage, and final result. task_tool reads that object while polling, writes custom stream events with get_stream_writer(), and eventually returns a string.
Cancellation is cooperative:
request_cancel_background_task sets a threading.Event
_aexecute checks it at agent.astream iteration boundaries
long tool calls may not stop until the next yielded chunk
Timeout also writes a terminal status through try_set_terminal, whose lock ensures only the first terminal result wins.
Returning To The Lead
The result path is separate from the subagent’s internal state.
subagent finishes
extract text from the final AIMessage
store it in result.result
task_tool
returns a plain string: "Task Succeeded. Result: ..."
ToolNode
wraps that string into a ToolMessage with the original tool_call_id
The lead does not receive the subagent’s full message history. Because checkpointer=False, that exploration history is not persisted as a resumable graph state. The only thing that returns is the distilled text result.
This is what the task tool means by preserving context through separate exploration: the subagent may perform a long, noisy investigation, while the lead receives only the final answer.
Why reuse create_agent
- Subagents share the same graph, tool, and middleware model as the lead.
- They inherit the execution context needed to work on the same workspace files.
- Tool and skill policies only narrow authority.
- The lead context receives a compact result instead of the whole exploration.
What it costs
- Delegation capability and tool visibility are coupled through
subagent_enabled. - Background execution relies on threads, a long-lived loop, and polling.
- Cancellation is cooperative and can be delayed by long tool calls.
- Task state is process-local rather than a first-class persistent runtime object.
Key Points
task_toolis a tool, not an agent.- A subagent is another graph created by
create_agent. SubagentLimitMiddlewaretrims excessive task calls; it does not create subagents.- Subagents inherit
sandbox,thread_data, andthread_id, but lose the task tool and checkpointing. ThreadStateis reused so subagent tools can read sandbox and thread data.- Tool policy is a narrowing funnel: parent scope, subagent config, skill allowed-tools.
- The lead receives a ToolMessage created from the string returned by
task_tool, not the subagent’s internal state. - Built-in prompts are source-defined; per-agent config can tune timeout, max turns, model, and skills, but not replace the built-in prompt.