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
)
Configuration controlling decomposition depth and thresholds
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>
Current recursion depth (max 3)
Parent tracing span for observability
Returns: Aggregated handoff combining all subtask results
Execution Flow:
- Creates Pi agent session with subplanner prompt
- Sends initial message with parent task context
- LLM responds with subtask array (or empty array if atomic)
- If empty array on first iteration → executes as single worker task
- Otherwise, dispatches subtasks in parallel
- Recursively decomposes subtasks if they exceed scope threshold
- 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.