DEV Community

Cover image for Code Agent Dissection (08): When a Task Is Too Complex, How Do You Delegate to a Sub-Agent?
WonderLab
WonderLab

Posted on

Code Agent Dissection (08): When a Task Is Too Complex, How Do You Delegate to a Sub-Agent?

Why the Main Agent Needs Sub-Agents

The main agent's context is finite. A complex task — like 'find all authentication-related code in the project and compile it into a report' — might require reading dozens of files and running many searches. These exploratory steps rapidly consume context, and mixed with the final 'generate the report' task, they increase the risk of compression and distortion.

The more fundamental issue: exploratory work (read-only, information-gathering) and generative work (writing code, modifying files) have completely different risk profiles. If you can isolate exploratory tasks and run them in a read-only sandbox where the main agent only receives the results, the benefits are clear:

  • The exploration process doesn't pollute the main agent's context
  • Sub-agents use lightweight models, reducing cost
  • Sub-agents can't write files, so even if manipulated, they can't cause side effects

MyCodeAgent's Task tool is the implementation of this mechanism.


The Conclusion Up Front

Main agent calls Task tool
    ↓
TaskTool.run()                     validates params, constructs TaskRequest
    ↓
_DeferredSubagentLauncher.launch() proxy layer, lazily initializes the real launcher
    ↓
SubagentLauncher.launch()          the real entry point (subagents.py)
    ├─ 1. look up RuntimeProfile (tool allowlist, step limit, token budget, model)
    ├─ 2. _select_llm(): prefer LIGHT_LLM, fall back to main model if not configured
    ├─ 3. _create_child_trace(): create independent trace log
    ├─ 4. emit subagent_requested/started events to parent trace
    ├─ 5. _SubagentRuntimeHost: create isolated sandbox (fresh history/context/tools)
    ├─ 6. _render_request(): combine task description + structured context into prompt
    ├─ 7. RuntimeRunner(host).run(prompt) ← full ReAct loop
    ├─ 8. _child_metrics(): extract terminal_reason/tool_usage/token_usage from trace events
    └─ 9. ExploreResult.from_json(): parse and validate sub-agent JSON output
    ↓
SubagentLaunchResult → TaskTool packages into standard envelope; main agent receives summary and continues
Enter fullscreen mode Exit fullscreen mode

The sub-agent runs a complete ReAct loop, using the same RuntimeRunner as the main agent. The difference is it runs in a constrained sandbox — tools, steps, and token budget are all strictly limited by RuntimeProfile.

Three launch layers that are easy to confuse:

Location Type Role
task.py TaskLauncher.launch Protocol interface Just an interface declaration, no implementation (...)
host.py _DeferredSubagentLauncher.launch Proxy layer Lazy initialization, forwards to the real launcher
subagents.py SubagentLauncher.launch Real implementation Creates sandbox, runs child loop, parses results

Why three layers: TaskTool is registered before SubagentLauncher can be created (CodeAgent hasn't finished initializing), and the proxy layer solves this timing issue; the Protocol interface means TaskTool doesn't directly depend on SubagentLauncher, keeping them decoupled.

Relationship between TaskRequest and SubagentRequest: They are mirror dataclasses with identical fields.

# task.py                          # subagents.py
class TaskRequest:                  class SubagentRequest:
    profile_name: str                   profile_name: str
    task: str                           task: str
    model_choice: str | None            model_choice: str | None
    structured_context: dict            structured_context: dict
    parent_session_id: str | None       parent_session_id: str | None
    parent_run_id: str | None           parent_run_id: str | None
Enter fullscreen mode Exit fullscreen mode

task.py can't directly import subagents.py (circular dependency), so it defines a mirror here. _DeferredSubagentLauncher.launch(request) passes TaskRequest directly to SubagentLauncher.launch(request: SubagentRequest) — Python's runtime only checks field values, not types, so they're fully compatible.

The first thing SubagentLauncher.launch() does is RUNTIME_PROFILES.get(request.profile_name)profile_name="explore" maps to EXPLORE_PROFILE, where sandbox constraints begin.


Task Tool: Entry Point, Parameter Validation, Full Call Chain

# tools/builtin/task.py — TaskTool.run() (simplified)
def run(self, parameters):
    description = parameters.get("description")  # short description for main agent (a label)
    prompt = parameters.get("prompt")            # self-contained exploration instruction (full task for sub-agent)
    profile = parameters.get("subagent_type")    # currently only "explore" is supported
    model = parameters.get("model", "light")     # "light" (lightweight model) or "main"

    # After parameter validation, construct TaskRequest and delegate
    launched = self._launcher.launch(
        TaskRequest(
            profile_name="explore",   # ← this string determines the path taken
            task=f"{description}\n\n{prompt}",
            model_choice=model,
        )
    )
Enter fullscreen mode Exit fullscreen mode

What is self._launcher? It's passed in when registering TaskTool in host.py:

# runtime/host.py — _initialize_runtime_components()
self.tool_registry.register_tool(
    TaskTool(
        project_root=...,
        launcher=self._DeferredSubagentLauncher(self._get_subagent_launcher),
        #                ↑ this is the real identity of self._launcher
    )
)
Enter fullscreen mode Exit fullscreen mode

So self._launcher.launch(request) follows this complete call chain:

TaskTool.run()
  self._launcher.launch(TaskRequest(profile_name="explore", ...))
    ↓  [host.py _DeferredSubagentLauncher.launch]
    self._get_launcher()          ← lazy init, only creates SubagentLauncher on first call
      ↓  [host.py _get_subagent_launcher]
      create_subagent_launcher(host)   ← factory.py, passes main agent's llm/registry in
        → SubagentLauncher(main_llm, tool_registry, ...)
    SubagentLauncher.launch(request)   ← subagents.py, the real implementation
      ↓
      profile = RUNTIME_PROFILES.get(request.profile_name)
      #         ↑ "explore" → EXPLORE_PROFILE (tool allowlist, step limit, token budget all here)
      ↓
      create sandbox, run ReAct loop, parse results
Enter fullscreen mode Exit fullscreen mode

The string profile_name="explore" is the connection point from TaskTool to EXPLORE_PROFILE — the first thing SubagentLauncher.launch() does is use it to look up the corresponding profile in the RUNTIME_PROFILES dictionary.

prompt must be self-contained — the sub-agent can't see the main agent's history, only this one instruction. When the model writes a Task call, it must explicitly write all background information into prompt; implicit context inheritance doesn't work.


RuntimeProfile: Defining Sandbox Constraints

All sub-agent constraints are declared in RuntimeProfile:

# runtime/subagents.py
EXPLORE_PROFILE = RuntimeProfile(
    name="explore",
    system_prompt=EXPLORE_SYSTEM_PROMPT,         # fixed system prompt requiring JSON return
    tool_allowlist={"Read", "Grep", "Glob"},     # read-only tools; no file writes, no command execution
    max_steps=12,                                # max 12 steps to prevent infinite loops
    context_token_budget=16_000,                 # single context window limit (main agent is 128k)
    total_token_budget=32_000,                   # cumulative token limit; forced termination if exceeded
    model_choice="light",                        # default to lightweight model for cost savings
    result_contract="ExploreResult",             # must return JSON matching this contract
)
Enter fullscreen mode Exit fullscreen mode

RuntimeProfile.__post_init__ has hard constraints — Task tools and Edit/Bash are not allowed in tool_allowlist:

if self.recursive_subagents or "Task" in self.tool_allowlist:
    raise ValueError("formal subagent profiles cannot recurse")
forbidden = {"Edit", "Bash"}
if forbidden & self.tool_allowlist:
    raise ValueError("formal subagent profiles must be strictly read-only")
Enter fullscreen mode Exit fullscreen mode

Why hard-code this in the profile instead of controlling it dynamically at runtime?

Profile is a declarative constraint that doesn't depend on runtime state. The main agent's history, current step count, and context compression state don't affect which tools the sub-agent can use — the sub-agent's boundaries are fixed in code and can't be bypassed by prompt injection.


SubagentLauncher.launch(): Sandbox Creation and Execution Details

Step 1: Select Model and Trace

# subagents.py — SubagentLauncher.launch()
llm, model_choice = self._select_llm(requested_model)
# _select_llm: prefer light_llm (created from LIGHT_LLM_* env vars)
# Falls back to main agent's main_llm if LIGHT_LLM_MODEL_ID is not set

child_trace = self._create_child_trace()
# Independent trace JSONL file; session_id starts with "child-"
# Separated from main agent trace, but launch() emits parent-child events
# linked through parent_session_id
Enter fullscreen mode Exit fullscreen mode

Step 2: Create Sandbox _SubagentRuntimeHost

_SubagentRuntimeHost is fully aligned with the main agent's CodeAgent structure — both have history_manager, context_engine, tool_executor, tool_orchestrator — but all state is freshly created; the main agent's history doesn't flow into the sub-agent.

RuntimeRunner is reused via duck typing: it only depends on attributes on the host, not class inheritance, so _SubagentRuntimeHost doesn't need to inherit from CodeAgent — as long as it has the same-named attributes, it can run the same loop.

Key constraints in the sandbox:

# 1. config: override key fields with profile budgets
self.config = Config.from_env().model_copy(update={
    "context_window": profile.context_token_budget,  # explore=16000, far less than main agent's 128k
})
self.max_steps = profile.max_steps          # explore=12, hard step limit
self.max_total_tokens = profile.total_token_budget  # explore=32000, forced termination if exceeded

# 2. registry: only contains allowlisted tools (build_registry filters)
# Of the main agent's tools, only Read/Grep/Glob enter the sub-agent's registry

# 3. ContextBuilder: system prompt fixed to profile.system_prompt (requires JSON return)
#    No MCP tool prompts or Skills loaded; non-allowlisted tool schemas not exposed

# 4. Context compression: uses _summarize_child_messages (pure truncation, no LLM)
#    Reason: sub-agent budget is small; LLM compression is too expensive; simple truncation suffices

# 5. Double-layer permission protection:
permission_context = PermissionContext(runtime_mode="readonly_subagent")
# RiskClassifier directly DENYs Edit/Bash/Task in this mode
# Even if these tools accidentally appear in the registry, they can't execute
# — allowlist + permission gate provide double protection

# 6. completion_verifier: _StructuredResultCompletionVerifier (sub-agent specific)
#    Checks if output matches result_contract JSON format; triggers FAIL feedback if not,
#    prompting sub-agent to re-output
Enter fullscreen mode Exit fullscreen mode

Step 3: Render Prompt and Run

# _render_request: combines task text and structured context into a prompt
# Output format: task text + "\n\nStructured context:\n" + JSON
# This is the only context the sub-agent sees — it can't see the main agent's history
prompt = _render_request(request)

# Same RuntimeRunner as main agent, runs full ReAct loop
# Sub-agent's system prompt requires it to ultimately output a JSON object
raw_result = RuntimeRunner(host).run(prompt)
Enter fullscreen mode Exit fullscreen mode

Step 4: Extract Metrics, Parse Results

# _child_metrics: iterates child_trace.events, aggregates three types of info:
# terminal_reason: how the sub-agent ended (completed/max_steps/token_budget, etc.)
# tool_usage: how many times each tool was called
# token_usage: cumulative token count
terminal_reason, tool_usage, token_usage = _child_metrics(child_trace.events)

# Sub-agent must end with completed/completed_unverified; any other reason is failure
if terminal_reason not in {"completed", "completed_unverified"}:
    raise ValueError(f"child terminal reason: {terminal_reason}")

# ExploreResult.from_json strict validation:
# - status can only be completed/partial
# - summary cannot be empty
# - if Markdown fence (```
{% endraw %}
) is present, raises exception immediately
# Invalid → launch() catches exception, returns status=FAILED
structured = ExploreResult.from_json(raw_result, tool_usage=tool_usage, ...)
{% raw %}

Enter fullscreen mode Exit fullscreen mode

Structured Result Contract

The sub-agent's system prompt requires it to return only JSON, not Markdown:


json
You are an Explore Agent.
Inspect the repository with read-only tools and return exactly one JSON object:
{"status":"completed|partial","summary":"...","findings":["..."],
"evidence":["relative/path.py:line"],"unresolved_questions":["..."]}.
Do not use markdown fences.


Enter fullscreen mode Exit fullscreen mode

After RuntimeRunner finishes, launch() parses this JSON:


python
raw_result = RuntimeRunner(host).run(prompt)

structured = ExploreResult.from_json(
    raw_result,
    tool_usage=tool_usage,
    terminal_reason=terminal_reason,
)


Enter fullscreen mode Exit fullscreen mode

ExploreResult.from_json() validates strictly: status can only be completed or partial, summary must have content, otherwise raises an exception, and launch() catches it and returns status=FAILED.

Why structured JSON rather than natural language?

After the main agent receives the sub-agent's result, it needs to reliably extract summary (the overview for the model), findings (specific findings), and evidence (code location evidence). Natural language requires the main agent to parse again, increasing the chance of distortion, and can't be format-validated. The JSON contract hard-codes 'what the sub-agent must provide' in the code.


Lightweight Model and LIGHT_LLM

Sub-agents default to model_choice="light", and _select_llm() tries to use light_llm:


python
def _select_llm(self, requested_model):
    if requested_model == "light":
        if self.light_llm is None:
            self.light_llm = _create_light_llm()   # created from LIGHT_LLM_* env vars
        if self.light_llm is not None:
            return self.light_llm, "light"
    return self.main_llm, "main"   # fall back to main model if no lightweight configured


Enter fullscreen mode Exit fullscreen mode

LIGHT_LLM_* env vars are configured in .env (LIGHT_LLM_PROVIDER, LIGHT_LLM_MODEL_ID, etc.); if not configured, falls back to the main agent's model. Exploration tasks typically only require reading code, searching, and summarizing — a lightweight model is sufficient, potentially reducing cost by an order of magnitude.


What the Main Agent Sees as Results

TaskTool.run() ultimately returns a standard envelope:


python
return self.success_result(
    data={
        "status": "completed",
        "profile": "explore",
        "result": {
            "summary": "Found 3 authentication-related code locations...",
            "findings": ["auth/login.py:45 — JWT verification", ...],
            "evidence": ["auth/login.py:45", ...],
        },
    },
    text=result.summary,   # ← summary for the model, goes directly into observation
    extra_stats={
        "tool_calls": 8,
        "token_usage": 12000,
        "model": "light",
    },
)


Enter fullscreen mode Exit fullscreen mode

The text field is the summary, appended directly as an observation to the main agent's history. The model sees this summary and decides what to do next. The full data has findings and evidence so the model can reference specific code locations in subsequent steps.


Current Limitations

The Task tool currently only supports subagent_type="explore" — passing any other value returns a parameter error. There's a VERIFICATION_PROFILE in the code, but no corresponding Task entry — the verification sub-agent is only called directly by the main loop's completion gate (enabled with --enable-verification-agent).

In multi-turn conversations, each Task call is independent — sub-agents have no memory across calls; each one gets a fresh _SubagentRuntimeHost. If you need to explore across multiple calls and accumulate findings incrementally, the main agent must manually include the previous call's findings in the next call's prompt.


Design Highlights

  1. Reuses the same RuntimeRunner: Sub-agents and main agent run the exact same ReAct loop — not a simplified version; capabilities are bounded by profile constraints
  2. Sandbox is declarative: Tool allowlist is hard-coded in the profile; can't be bypassed at runtime; doesn't rely on the model following rules
  3. Structured contract: JSON results let the main agent reliably extract information without re-parsing natural language
  4. Lightweight model reduces cost: Exploration tasks don't need the strongest model; configuring LIGHT_LLM can save significantly
  5. Independent trace: Sub-agents have their own trace file; parent-child events are linked through parent_session_id for independent sub-task analysis

Summary

Mechanism Role
TaskTool Parameter validation + delegation entry point
RuntimeProfile Declares sandbox constraints (tools, steps, tokens, model)
SubagentLauncher Creates sandbox, selects model, runs child loop, parses results
_SubagentRuntimeHost Independent history/context/tools, fully isolated from main agent
ExploreResult JSON structured contract ensuring machine-parseable results
readonly_subagent permission mode Double protection: allowlist + permission classifier block writes

About the Source Code

All analysis in this series is based on the open-source project MyCodeAgent.

The source code includes inline comments aligned with the series' explanation order at key locations — you can follow along while reading, or clone it, run it, modify it, and extend it to build your own agent.


bash
git clone https://github.com/chendongqi/MyCodeAgent
cd MyCodeAgent
cp .env.example .env   # fill in your LLM API key
uv sync
uv run python main.py


Enter fullscreen mode Exit fullscreen mode

Check out PrimeSkills — a curated marketplace of AI agents and skills that have been validated in real-world, enterprise-grade workflows. No fluff, just what actually works.

Find more useful knowledge and interesting products on my Homepage

Top comments (0)