Skip to main content

Overview

The Subplanner handles recursive task decomposition for complex tasks that span multiple files. It runs its own mini planning loop to break down a parent task into smaller subtasks, executing them in parallel. Location: packages/orchestrator/src/subplanner.ts

Class: Subplanner

Constructor

new Subplanner(
  config: OrchestratorConfig,
  subplannerConfig: SubplannerConfig,
  taskQueue: TaskQueue,
  workerPool: WorkerPool,
  mergeQueue: MergeQueue,
  monitor: Monitor,
  systemPrompt: string
)
subplannerConfig
SubplannerConfig
required
Configuration controlling decomposition depth and thresholds
systemPrompt
string
required
Loaded from prompts/subplanner.md

Core Method

decomposeAndExecute()

Recursively decomposes and executes a complex task.
async decomposeAndExecute(
  parentTask: Task,
  depth: number = 0,
  parentSpan?: Span
): Promise<Handoff>
parentTask
Task
required
Task to decompose
depth
number
default:"0"
Current recursion depth (max 3)
parentSpan
Span
Parent tracing span for observability
Returns: Aggregated handoff combining all subtask results Execution Flow:
  1. Creates Pi agent session with subplanner prompt
  2. Sends initial message with parent task context
  3. LLM responds with subtask array (or empty array if atomic)
  4. If empty array on first iteration → executes as single worker task
  5. Otherwise, dispatches subtasks in parallel
  6. Recursively decomposes subtasks if they exceed scope threshold
  7. Aggregates all handoffs into single parent handoff

Configuration

SubplannerConfig

interface SubplannerConfig {
  maxDepth: number          // Maximum recursion depth
  scopeThreshold: number    // Min files for decomposition
  maxSubtasks: number       // Max subtasks per decomposition
}
Default Configuration:
const DEFAULT_SUBPLANNER_CONFIG: SubplannerConfig = {
  maxDepth: 3,
  scopeThreshold: 4,
  maxSubtasks: 10
}

Decomposition Logic

shouldDecompose()

Determines if a task should be decomposed.
function shouldDecompose(
  task: Task,
  config: SubplannerConfig,
  currentDepth: number
): boolean
Decomposition Criteria:
  • Current depth < max depth (3)
  • Task scope >= threshold (4 files)
Example:
if (shouldDecompose(task, config, 0)) {
  // Task has 4+ files and depth < 3 → decompose
  handoff = await subplanner.decomposeAndExecute(task, 0)
} else {
  // Task is atomic → execute directly
  handoff = await workerPool.assignTask(task)
}

Handoff Aggregation

aggregateHandoffs()

Combines multiple subtask handoffs into a single parent handoff.
function aggregateHandoffs(
  parentTask: Task,
  subtasks: Task[],
  handoffs: Handoff[]
): Handoff
Aggregation Rules: Status:
  • All subtasks complete → "complete"
  • All subtasks failed → "failed"
  • Some completed → "partial"
  • None completed → "blocked"
Summary:
const summary = 
  `Decomposed "${parentTask.description}" into ${totalSubtasks} subtasks. ` +
  `${completedCount} complete, ${failedCount} failed.\n\n` +
  subtaskSummaries.join("\n")
Files Changed: Union of all subtask file changes Concerns: Prefixed with subtask ID [task-001-sub-1] <concern> Metrics: Summed across all subtasks (tokens, lines, etc.)

Subtask Creation

buildSubtasksFromRaw()

Converts LLM output into typed subtask objects.
private buildSubtasksFromRaw(
  rawTasks: RawTaskInput[],
  parentTask: Task,
  dispatchedTaskIds: Set<string>
): Task[]
Scope Validation: Subtask scopes are validated against parent scope:
const invalidFiles = subtaskScope.filter(f => 
  !parentTask.scope.includes(f)
)
if (invalidFiles.length > 0) {
  // Remove invalid files, warn, potentially skip subtask
}
ID Generation:
const id = raw.id || `${parentTask.id}-sub-${subCounter}`
// Example: "task-005-sub-1", "task-005-sub-2"
Branch Generation:
const branch = raw.branch || 
  `${config.git.branchPrefix}${id}-${slugifyForBranch(description)}`
// Example: "worker/task-005-sub-1-implement-user-auth"

Message Builders

Initial Message

private buildInitialMessage(
  parentTask: Task,
  repoState: RepoState,
  depth: number
): string
Includes:
  • Parent task details (ID, description, scope, acceptance, priority)
  • Current decomposition depth
  • Repository file tree
  • Recent commits
  • FEATURES.json (if exists)
  • Instructions for JSON response format
Response Format:
{
  "scratchpad": "Analysis and decomposition plan",
  "tasks": [
    {
      "id": "task-005-sub-1",
      "description": "Implement user authentication",
      "scope": ["src/auth/login.ts", "src/auth/session.ts"],
      "acceptance": "Login endpoint returns JWT token",
      "priority": 5
    }
  ]
}

Follow-up Message

private buildFollowUpMessage(
  repoState: RepoState,
  newHandoffs: Handoff[],
  activeTasks: Set<string>,
  dispatchedTaskIds: Set<string>
): string
Includes:
  • Updated repo state (file tree, commits)
  • New subtask handoffs since last plan
  • Currently active subtasks
  • Instruction to return empty tasks array when done

Callbacks

onSubtaskCreated()

onSubtaskCreated(
  callback: (subtask: Task, parentId: string) => void
): void
Called when a subtask is created during decomposition.

onSubtaskCompleted()

onSubtaskCompleted(
  callback: (subtask: Task, handoff: Handoff, parentId: string) => void
): void
Called when a subtask completes.

onDecomposition()

onDecomposition(
  callback: (parentTask: Task, subtasks: Task[], depth: number) => void
): void
Called when decomposition creates new subtasks.

onError()

onError(callback: (error: Error, parentTaskId: string) => void): void
Called on decomposition errors.

Constants

const LOOP_SLEEP_MS = 500                 // Iteration sleep
const MIN_HANDOFFS_FOR_REPLAN = 1         // Lower than root planner
const MAX_SUBPLANNER_ITERATIONS = 20      // Max iterations per decomposition
const MAX_CONSECUTIVE_ERRORS = 5          // Error threshold

Failure Handling

createFailureHandoff()

Creates a failure handoff when decomposition fails.
function createFailureHandoff(task: Task, error: Error): Handoff
Returns handoff with:
  • Status: "failed"
  • Summary: Error message
  • Suggestion: “Consider sending this task directly to a worker”

Usage Example

import { Subplanner, shouldDecompose, DEFAULT_SUBPLANNER_CONFIG } from '@longshot/orchestrator'

const subplanner = new Subplanner(
  config,
  DEFAULT_SUBPLANNER_CONFIG,
  taskQueue,
  workerPool,
  mergeQueue,
  monitor,
  subplannerPrompt
)

// Check if task needs decomposition
if (shouldDecompose(task, DEFAULT_SUBPLANNER_CONFIG, 0)) {
  const handoff = await subplanner.decomposeAndExecute(task, 0)
  console.log(`Decomposed into ${handoff.metrics.filesModified} file changes`)
}

// Listen for decomposition events
subplanner.onDecomposition((parent, subtasks, depth) => {
  console.log(`Decomposed ${parent.id} into ${subtasks.length} subtasks at depth ${depth}`)
})

Recursion Example

Root Task (15 files) → shouldDecompose(depth=0) → YES
  ├─ Subtask 1 (5 files) → shouldDecompose(depth=1) → YES
  │   ├─ Subtask 1.1 (2 files) → shouldDecompose(depth=2) → NO → Worker
  │   └─ Subtask 1.2 (3 files) → shouldDecompose(depth=2) → NO → Worker
  ├─ Subtask 2 (6 files) → shouldDecompose(depth=1) → YES
  │   ├─ Subtask 2.1 (3 files) → shouldDecompose(depth=2) → NO → Worker
  │   └─ Subtask 2.2 (3 files) → shouldDecompose(depth=2) → NO → Worker
  └─ Subtask 3 (4 files) → shouldDecompose(depth=1) → YES
      ├─ Subtask 3.1 (2 files) → shouldDecompose(depth=2) → NO → Worker
      └─ Subtask 3.2 (2 files) → shouldDecompose(depth=2) → NO → Worker
Max depth of 3 prevents infinite recursion while allowing sufficient decomposition.

Build docs developers (and LLMs) love