← AI Agents Architecture & Patterns
🤖 AI Agents

Task Decomposition Algorithms for AI Agents

Source: mortalapps.com
TL;DR
  • Task decomposition is the algorithmic process of breaking down an ambiguous, high-level prompt into a structured, executable Directed Acyclic Graph (DAG) of sub-tasks.
  • It solves the critical problems of planning failures, context window exhaustion, and execution drift in complex, multi-step agent workflows.
  • In production, structured decomposition enables parallel execution, precise cost attribution, and localized error recovery without restarting the entire run.
  • This tutorial provides a complete, type-safe Python implementation of a dependency-aware decomposition engine using Pydantic and topological sorting.

Why This Matters

In complex agentic systems, executing a high-level, ambiguous user prompt directly through a single LLM call leads to high failure rates, hallucinated steps, and state drift. Algorithmic ai agent task decomposition resolves this by translating unstructured natural language into a structured Directed Acyclic Graph (DAG) of discrete, executable sub-tasks before any execution begins. This approach was invented to mirror human cognitive planning (such as Chain of Thought and Least-to-Most prompting) but formalized into software engineering constructs like dependency trees and topological execution queues.

If you ignore structured task decomposition in production, your agentic workflows will suffer from catastrophic cascading failures. A single API timeout or minor validation error in a late-stage step will force you to re-run the entire sequence, wasting expensive tokens and introducing unpredictable latencies. Furthermore, without a clear DAG, parallel execution of independent sub-tasks is impossible, severely limiting system throughput.

Use structured task decomposition when the user prompt requires multi-step orchestration, external tool integrations, or parallel processing paths (e.g., generating a comprehensive market report with parallel data fetching, synthesis, and formatting). Avoid it for simple, single-turn tasks or linear workflows where a lightweight state machine or simple router is more cost-effective and introduces less latency overhead. By decoupling planning from execution, engineering teams can implement deterministic validation, localized retries, and precise cost attribution at the individual sub-task level.

Core Concepts

To build a robust task decomposition engine, you must understand the following core concepts:

  • Directed Acyclic Graph (DAG): A directed graph with no directed cycles. In task decomposition, nodes represent sub-tasks (e.g., "Fetch API Data") and directed edges represent execution dependencies (e.g., Task B cannot start until Task A completes).
  • Granularity Heuristics: Rules or metrics used by the planner to determine when a sub-task is sufficiently atomic. Over-decomposition leads to token waste and orchestrator latency; under-decomposition leads to monolithic execution failures.
  • Topological Sorting: An algorithmic ordering of DAG vertices such that for every directed edge $u \to v$, vertex $u$ comes before $v$ in the ordering. This determines the exact execution sequence.
  • Dynamic Re-planning: The process of modifying the remaining DAG at runtime when a sub-task fails, returns unexpected data, or reveals new execution requirements.
  • Sub-task State: The execution context of a single node, containing its inputs, outputs, status (Pending, Running, Completed, Failed), and execution metadata.
Granularity Level Description Pros Cons
Monolithic Single LLM call handles all steps Zero orchestration overhead High failure rate, no parallelization
Compound Coarse-grained steps (e.g., "Research & Write") Moderate token cost, simpler graphs Hard to recover from localized errors
Atomic Fine-grained steps (e.g., "Fetch URL", "Parse HTML") High parallelization, robust recovery High orchestration latency and token cost

How It Works

The task decomposition lifecycle consists of four distinct phases that transform an ambiguous prompt into a successfully executed set of sub-tasks.

Phase 1: Prompt Parsing and Schema Alignment

The user input is sent to the Planner LLM along with a strict JSON schema representing the target DAG. The system prompt instructs the LLM to analyze the request, identify necessary external tools, and break the request down into discrete steps. The output must conform to a structured schema containing an array of tasks, each with a unique ID, a task type, input parameters, and an array of dependency IDs.

Phase 2: Dependency Mapping and DAG Validation

Once the raw plan is generated, the orchestrator validates the graph structure. It parses the dependencies to ensure there are no circular references (e.g., Task A depends on Task B, which depends on Task A). If a cycle is detected, the plan is rejected, and a repair prompt is sent back to the LLM. The orchestrator also validates that all task inputs mapped from parent outputs are type-compatible.

Phase 3: Topological Sorting and Execution Queueing

The validated DAG is passed to a topological sorting algorithm (such as Kahn's algorithm). This sort produces a linear execution queue or, more commonly, a tiered execution plan where independent tasks are grouped together. Tasks with zero dependencies are marked as "Ready" and pushed to an active execution queue.

Phase 4: Runtime Execution and Failure Handling

The orchestrator pops "Ready" tasks from the queue and dispatches them to worker threads or async runners. When a task completes, its output is stored in the shared state context. The orchestrator then checks all downstream tasks that depended on the completed task. If all of a downstream task's dependencies are resolved, its state transitions to "Ready" and it is queued for execution. If a task fails, the orchestrator halts downstream execution of dependent nodes, triggers localized retries, or invokes the Planner LLM to dynamically re-plan the remaining graph based on the failure context.

Architecture

The Task Decomposition Engine architecture consists of six core components. Execution starts when a user submits a prompt to the API Gateway and ends when the final aggregator node returns the synthesized result.

  1. API Gateway: Receives the unstructured user prompt and forwards it to the orchestrator.
  2. Planner LLM (Decomposer): A high-capability LLM configured with structured output schemas. It parses the prompt and generates the raw task nodes and dependency edges.
  3. DAG Validator: A deterministic software component that validates the graph structure, checking for cycles using Depth-First Search (DFS) and verifying type-safety of input/output mappings.
  4. State Store: A persistent or in-memory database (e.g., Redis or PostgreSQL) that tracks the state, inputs, outputs, and execution logs of every sub-task.
  5. Execution Orchestrator: An asynchronous engine that performs topological sorting, manages the execution queue, dispatches tasks to workers, and handles state transitions.
  6. Worker Pool: A set of concurrent execution environments (threads, processes, or sandboxed containers) that run the actual tools or sub-agents associated with each task node.

Data flows from the API Gateway to the Planner, then through the Validator to the State Store. The Orchestrator continuously reads from the State Store, dispatches work to the Worker Pool, and writes execution results back to the State Store until the graph is exhausted.

Granularity Heuristics: Finding the Atomic Sweet Spot

Determining the optimal size of a sub-task is the most challenging aspect of task decomposition. If tasks are too large, the agent behaves like a monolithic system, losing the benefits of parallelization and localized recovery. If tasks are too small, the system suffers from "orchestration bloat," where the latency and cost of LLM planning calls exceed the actual execution time of the tasks.

To programmatically guide the Planner LLM, you must inject granularity heuristics into the system prompt. These heuristics should define:

  • Single Responsibility Principle: A sub-task must perform exactly one action (e.g., call one API, write one file, or synthesize one set of data).
  • Context Window Boundaries: A sub-task's input and output data must fit comfortably within a localized context window (typically under 4,000 tokens) to ensure high-quality processing.
  • Tool Isolation: Any step requiring an external tool call must be isolated into its own sub-task. This allows the orchestrator to handle tool-specific timeouts, rate limits, and authentication failures independently.

Algorithmic DAG Validation: Cycle Detection and Schema Enforcement

Before executing any LLM-generated plan, you must programmatically guarantee that the graph is a valid DAG. LLMs frequently generate circular dependencies when dealing with complex, multi-step prompts.

To detect cycles, implement a Depth-First Search (DFS) algorithm using three-color marking:

  • White (Unvisited): Nodes that have not been processed yet.
  • Gray (Visiting): Nodes currently in the recursion stack. If you encounter a Gray node during traversal, a cycle exists.
  • Black (Visited): Nodes that have been fully processed along with all their descendants.

If the validator detects a cycle, it must raise a validation error. In production, this error should trigger a self-correction loop, feeding the cyclic path back to the Planner LLM with instructions to break the loop.

Topological Sort and Parallel Execution Orchestration

Once validated, the DAG must be executed efficiently. A simple linear execution of a topological sort wastes the opportunity for parallel processing. Instead, use an event-driven orchestrator that tracks the in-degree (number of incoming dependencies) of each node.

  1. Calculate the in-degree for all nodes in the DAG.
  2. Identify all nodes with an in-degree of 0 and push them to the active execution queue.
  3. As each task completes, decrement the in-degree of its downstream neighbor nodes.
  4. If a neighbor node's in-degree reaches 0, push it to the active queue.
  5. Repeat until all nodes are executed or a terminal failure occurs.

This approach naturally maximizes concurrency, allowing independent branches of the graph to run in parallel across the Worker Pool.

Mid-Execution Failure Recovery and Dynamic Re-planning

In real-world environments, sub-tasks will fail due to network timeouts, API rate limits, or unexpected data formats. A robust task decomposition engine must handle these failures gracefully without restarting the entire DAG.

There are three primary recovery strategies:

  1. Localized Retries with Exponential Backoff: For transient errors (e.g., HTTP 503), the orchestrator retries the specific node up to a configured limit. Downstream nodes remain paused in the "Pending" state.
  2. Alternative Branch Execution: If a node fails permanently, the orchestrator can execute a pre-planned fallback branch (e.g., switching from a premium search API to a local database query).
  3. Dynamic Re-planning: For structural failures, the orchestrator halts execution, packages the current state of the DAG (including completed task outputs and the failure error message), and sends it back to the Planner LLM. The LLM then generates a *new* partial DAG to replace the remaining unexecuted nodes.

Code Example

A complete, runnable Python implementation of a Task Decomposition Engine. It uses Pydantic to enforce a strict DAG schema, implements DFS cycle detection, performs topological sorting, and executes the tasks concurrently using an asynchronous event loop.
Python
import asyncio
import logging
import os
from enum import Enum
from typing import List, Dict, Any, Set
from pydantic import BaseModel, Field
from openai import OpenAI

# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger("TaskDecomposer")

# Define the schema for structured output
class TaskType(str, Enum):
    FETCH_DATA = "fetch_data"
    ANALYZE_DATA = "analyze_data"
    GENERATE_REPORT = "generate_report"
    VALIDATE_OUTPUT = "validate_output"

class SubTask(BaseModel):
    id: str = Field(..., description="Unique identifier for the task, e.g., 'task_01'")
    type: TaskType = Field(..., description="The type of action to perform")
    description: str = Field(..., description="Detailed description of what this sub-task does")
    dependencies: List[str] = Field(default_factory=list, description="List of task IDs that must complete before this task starts")
    input_parameters: Dict[str, Any] = Field(default_factory=dict, description="Parameters required for execution")

class TaskDAG(BaseModel):
    tasks: List[SubTask] = Field(..., description="The complete list of sub-tasks making up the DAG")

# Cycle Detection using DFS (Three-Color Marking)
class CycleDetector:
    def __init__(self, tasks: List[SubTask]):
        self.adj_list: Dict[str, List[str]] = {t.id: t.dependencies for t in tasks}
        self.visited: Dict[str, str] = {t.id: "WHITE" for t in tasks} # WHITE, GRAY, BLACK

    def has_cycle(self) -> bool:
        for node in self.adj_list:
            if self.visited[node] == "WHITE":
                if self._dfs(node):
                    return True
        return False

    def _dfs(self, node: str) -> bool:
        self.visited[node] = "GRAY"
        for neighbor in self.adj_list.get(node, []):
            # If neighbor is not in graph, log error and skip (or treat as cycle/invalid)
            if neighbor not in self.visited:
                logger.warning(f"Task {node} depends on non-existent task {neighbor}")
                continue
            if self.visited[neighbor] == "GRAY":
                return True # Cycle detected
            if self.visited[neighbor] == "WHITE":
                if self._dfs(neighbor):
                    return True
        self.visited[node] = "BLACK"
        return False

# Mock Tool Executor
async def execute_tool(task: SubTask, context: Dict[str, Any]) -> Dict[str, Any]:
    logger.info(f"Starting execution of task: {task.id} ({task.type.value})")
    # Simulate network latency
    await asyncio.sleep(1.0)
    
    # Resolve inputs that depend on parent outputs
    resolved_inputs = {}
    for k, v in task.input_parameters.items():
        if isinstance(v, str) and v.startswith("$"):
            parent_key = v[1:]
            resolved_inputs[k] = context.get(parent_key, f"[Resolved {parent_key}]")
        else:
            resolved_inputs[k] = v

    # Execute mock logic based on task type
    if task.type == TaskType.FETCH_DATA:
        result = {"status": "success", "data": f"Raw data fetched for {resolved_inputs.get('source', 'unknown')}"}
    elif task.type == TaskType.ANALYZE_DATA:
        result = {"status": "success", "analysis": f"Analyzed: {resolved_inputs.get('data', '')}"}
    elif task.type == TaskType.GENERATE_REPORT:
        result = {"status": "success", "report": f"Report generated based on: {resolved_inputs.get('analysis', '')}"}
    else:
        result = {"status": "success", "message": "Task executed successfully"}
        
    logger.info(f"Completed task: {task.id}")
    return result

# Orchestrator Engine
class DAGOrchestrator:
    def __init__(self, dag: TaskDAG):
        self.dag = dag
        self.tasks_by_id = {t.id: t for t in dag.tasks}
        self.context: Dict[str, Any] = {}
        self.completed_tasks: Set[str] = set()
        self.running_tasks: Set[str] = set()

    async def run(self) -> Dict[str, Any]:
        # Validate DAG before running
        detector = CycleDetector(self.dag.tasks)
        if detector.has_cycle():
            raise ValueError("Catastrophic Error: Circular dependency detected in generated task plan.")

        while len(self.completed_tasks) < len(self.dag.tasks):
            # Find tasks ready to execute (all dependencies are in completed_tasks)
            ready_tasks = []
            for task in self.dag.tasks:
                if task.id in self.completed_tasks or task.id in self.running_tasks:
                    continue
                if all(dep in self.completed_tasks for dep in task.dependencies):
                    ready_tasks.append(task)

            if not ready_tasks and not self.running_tasks:
                raise RuntimeError("Execution stalled. Unresolved dependencies or disconnected graph.")

            # Launch ready tasks concurrently
            futures = []
            for task in ready_tasks:
                self.running_tasks.add(task.id)
                futures.append(self._run_task_wrapper(task))

            if futures:
                await asyncio.gather(*futures)
            else:
                # Wait briefly for running tasks to complete
                await asyncio.sleep(0.1)

        return self.context

    async def _run_task_wrapper(self, task: SubTask):
        try:
            output = await execute_tool(task, self.context)
            self.context[task.id] = output
            self.completed_tasks.add(task.id)
        except Exception as e:
            logger.error(f"Task {task.id} failed: {str(e)}")
            raise e
        finally:
            self.running_tasks.remove(task.id)

# Planner / Decomposer using OpenAI Structured Outputs
def decompose_prompt(user_prompt: str) -> TaskDAG:
    api_key = os.environ.get("OPENAI_API_KEY")
    if not api_key:
        raise ValueError("OPENAI_API_KEY environment variable is not set.")

    client = OpenAI(api_key=api_key)
    
    system_instruction = (
        "You are an expert AI planner. Break down the user's request into a set of discrete sub-tasks "
        "represented as a Directed Acyclic Graph (DAG). Ensure tasks have clear dependencies. "
        "Use the format '$task_id' in input_parameters to reference outputs of parent tasks."
    )

    completion = client.beta.chat.completions.parse(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": system_instruction},
            {"role": "user", "content": user_prompt}
        ],
        response_format=TaskDAG,
    )
    return completion.choices[0].message.parsed

# Execution Entrypoint
async def main():
    # Example complex prompt requiring parallel fetching and sequential analysis
    prompt = "Fetch data from Google and Bing, analyze both datasets, and generate a final market report."
    
    try:
        logger.info(f"Decomposing prompt: '{prompt}'")
        dag = decompose_prompt(prompt)
        
        logger.info(f"Decomposition successful. Generated {len(dag.tasks)} tasks.")
        for t in dag.tasks:
            logger.info(f"  - Task {t.id}: {t.description} (Depends on: {t.dependencies})")

        orchestrator = DAGOrchestrator(dag)
        final_context = await orchestrator.run()
        logger.info("All tasks executed successfully!")
        logger.info(f"Final Context Keys: {list(final_context.keys())}")
    except Exception as e:
        logger.error(f"Execution failed: {str(e)}")

if __name__ == "__main__":
    # Ensure OPENAI_API_KEY is set in your environment before running
    asyncio.run(main())
Expected Output
2026-03-30 10:00:00 - INFO - Decomposing prompt: 'Fetch data from Google and Bing, analyze both datasets, and generate a final market report.'
2026-03-30 10:00:02 - INFO - Decomposition successful. Generated 4 tasks.
2026-03-30 10:00:02 - INFO -   - Task task_01: Fetch data from Google (Depends on: [])
2026-03-30 10:00:02 - INFO -   - Task task_02: Fetch data from Bing (Depends on: [])
2026-03-30 10:00:02 - INFO -   - Task task_03: Analyze Google and Bing data (Depends on: ['task_01', 'task_02'])
2026-03-30 10:00:02 - INFO -   - Task task_04: Generate final market report (Depends on: ['task_03'])
2026-03-30 10:00:02 - INFO - Starting execution of task: task_01 (fetch_data)
2026-03-30 10:00:02 - INFO - Starting execution of task: task_02 (fetch_data)
2026-03-30 10:00:03 - INFO - Completed task: task_01
2026-03-30 10:00:03 - INFO - Completed task: task_02
2026-03-30 10:00:03 - INFO - Starting execution of task: task_03 (analyze_data)
2026-03-30 10:00:04 - INFO - Completed task: task_03
2026-03-30 10:00:04 - INFO - Starting execution of task: task_04 (generate_report)
2026-03-30 10:00:05 - INFO - Completed task: task_04
2026-03-30 10:00:05 - INFO - All tasks executed successfully!
2026-03-30 10:00:05 - INFO - Final Context Keys: ['task_01', 'task_02', 'task_03', 'task_04']

Key Takeaways

Task decomposition translates ambiguous natural language prompts into structured, valid Directed Acyclic Graphs (DAGs).
Deterministic validation (such as cycle detection) is mandatory to prevent execution loops caused by LLM planning errors.
Topological sorting allows orchestrators to maximize concurrency by executing independent sub-tasks in parallel.
Decoupling planning from execution enables localized error recovery, targeted retries, and dynamic re-planning.
Granularity heuristics must be explicitly defined in system prompts to prevent over-decomposition and orchestration bloat.
Enterprise deployments require strict RBAC propagation, comprehensive audit logging, and hard token budgets.

Frequently Asked Questions

What is the difference between task decomposition and chain of thought? +
Chain of Thought is a linear, inline reasoning process executed within a single LLM call. Task decomposition is an architectural pattern that outputs a structured graph (DAG) of multiple, separate execution steps managed by an external orchestrator.
When should I avoid task decomposition? +
Avoid task decomposition for simple, single-turn tasks, highly linear workflows, or latency-critical applications where the 2-3 second overhead of the planning LLM call is unacceptable.
How do you handle circular dependencies generated by the LLM? +
You must validate the generated graph using a deterministic cycle-detection algorithm (like DFS) before execution. If a cycle is found, reject the plan and send the error context back to the LLM for re-planning.
What happens if a sub-task fails mid-execution? +
The orchestrator halts downstream execution of dependent nodes. It can then trigger a localized retry, execute a pre-planned fallback branch, or invoke the Planner LLM to dynamically re-plan the remaining unexecuted graph.
How do downstream tasks access the outputs of upstream tasks? +
The orchestrator maintains a shared state context. Upstream tasks write their outputs to this context, and downstream tasks reference those outputs using variable templates (e.g., `$task_id.output_field`) which the orchestrator resolves before execution.
Can task decomposition run on local, smaller LLMs? +
While possible, smaller models (under 70B parameters) struggle with complex dependency mapping and strict schema adherence. It is recommended to use highly capable models (like GPT-4o or Claude 3.5 Sonnet) for the planning phase.
How does task decomposition improve cost efficiency? +
By breaking a complex task into smaller steps, you only execute expensive reasoning models where needed. Simple steps can be routed to cheaper models or deterministic code, and failed steps can be retried without re-running the entire workflow.
What is the best database for storing decomposed task states? +
Redis is excellent for low-latency, in-memory state tracking and queue management in high-throughput systems. For long-running, complex workflows requiring strict auditability, PostgreSQL with JSONB support is preferred.