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

A deep dive into MyCodeAgent's sub-agent mechanism: the Task tool, RuntimeProfile sandbox constraints, SubagentLauncher's complete execution chain, and structured result contracts. Understand how the main agent delegates exploratory tasks to a read-only lightweight sub-agent, and why sub-agents are not 'simplified main agents' but strictly constrained execution units.

·13 min read·AI Engineering

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

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:

LocationTypeRole
task.py TaskLauncher.launchProtocol interfaceJust an interface declaration, no implementation (...)
host.py _DeferredSubagentLauncher.launchProxy layerLazy initialization, forwards to the real launcher
subagents.py SubagentLauncher.launchReal implementationCreates 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

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,
        )
    )

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
    )
)

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

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
)

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")

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

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

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)

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 (```) is present, raises exception immediately
# Invalid → launch() catches exception, returns status=FAILED
structured = ExploreResult.from_json(raw_result, tool_usage=tool_usage, ...)

Structured Result Contract

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

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.

After RuntimeRunner finishes, launch() parses this JSON:

raw_result = RuntimeRunner(host).run(prompt)
 
structured = ExploreResult.from_json(
    raw_result,
    tool_usage=tool_usage,
    terminal_reason=terminal_reason,
)

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:

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

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:

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",
    },
)

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

MechanismRole
TaskToolParameter validation + delegation entry point
RuntimeProfileDeclares sandbox constraints (tools, steps, tokens, model)
SubagentLauncherCreates sandbox, selects model, runs child loop, parses results
_SubagentRuntimeHostIndependent history/context/tools, fully isolated from main agent
ExploreResultJSON structured contract ensuring machine-parseable results
readonly_subagent permission modeDouble 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.

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

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