Skip to main content

ralph/commands/task/decompose/
planning.rs

1//! Planner prompt execution and response parsing for task decomposition.
2//!
3//! Responsibilities:
4//! - Load the decomposition prompt template and invoke the configured runner.
5//! - Resolve source/attach metadata into preview inputs before planner execution.
6//! - Parse planner JSON output into normalized preview trees and write blockers.
7//!
8//! Not handled here:
9//! - Queue mutation or undo snapshot creation.
10//! - Tree normalization internals or task materialization helpers.
11//!
12//! Invariants/assumptions:
13//! - Planner output must contain a final JSON object matching the decomposition schema.
14//! - Preview planning remains read-only with respect to queue/done files.
15
16use super::super::resolve_task_runner_settings;
17use super::resolve::{compute_write_blockers, resolve_attach_target, resolve_source};
18use super::support::kind_for_source;
19use super::tree::normalize_response;
20use super::types::{
21    DecompositionAttachTarget, DecompositionPreview, DecompositionSource, RawDecompositionResponse,
22    TaskDecomposeOptions,
23};
24use crate::commands::run::PhaseType;
25use crate::contracts::ProjectType;
26use crate::{config, prompts, queue, runner, runutil};
27use anyhow::{Context, Result};
28
29pub fn plan_task_decomposition(
30    resolved: &config::Resolved,
31    opts: &TaskDecomposeOptions,
32) -> Result<DecompositionPreview> {
33    let (active, done) = queue::load_and_validate_queues(resolved, true)?;
34    let source = resolve_source(resolved, &active, done.as_ref(), opts.source_input.trim())?;
35    let attach_target = resolve_attach_target(
36        resolved,
37        &active,
38        done.as_ref(),
39        opts.attach_to_task_id.as_deref(),
40        &source,
41    )?;
42
43    let template = prompts::load_task_decompose_prompt(&resolved.repo_root)?;
44    let prompt = build_planner_prompt(resolved, opts, &source, attach_target.as_ref(), &template)?;
45    let settings = resolve_task_runner_settings(
46        resolved,
47        opts.runner_override.clone(),
48        opts.model_override.clone(),
49        opts.reasoning_effort_override,
50        &opts.runner_cli_overrides,
51    )?;
52    let bins = runner::resolve_binaries(&resolved.config.agent);
53    let retry_policy = runutil::RunnerRetryPolicy::from_config(&resolved.config.agent.runner_retry)
54        .unwrap_or_default();
55
56    let output = runutil::run_prompt_with_handling(
57        runutil::RunnerInvocation {
58            repo_root: &resolved.repo_root,
59            runner_kind: settings.runner,
60            bins,
61            model: settings.model,
62            reasoning_effort: settings.reasoning_effort,
63            runner_cli: settings.runner_cli,
64            prompt: &prompt,
65            timeout: None,
66            permission_mode: settings.permission_mode,
67            revert_on_error: false,
68            git_revert_mode: resolved
69                .config
70                .agent
71                .git_revert_mode
72                .unwrap_or(crate::contracts::GitRevertMode::Ask),
73            output_handler: None,
74            output_stream: runner::OutputStream::Terminal,
75            revert_prompt: None,
76            phase_type: PhaseType::SinglePhase,
77            session_id: None,
78            retry_policy,
79        },
80        runutil::RunnerErrorMessages {
81            log_label: "task decompose planner",
82            interrupted_msg: "Task decomposition interrupted: the planner run was canceled.",
83            timeout_msg: "Task decomposition timed out before a plan was returned.",
84            terminated_msg: "Task decomposition terminated: the planner was stopped by a signal.",
85            non_zero_msg: |code| {
86                format!(
87                    "Task decomposition failed: the planner exited with a non-zero code ({code})."
88                )
89            },
90            other_msg: |err| {
91                format!(
92                    "Task decomposition failed: the planner could not be started or encountered an error. Error: {:#}",
93                    err
94                )
95            },
96        },
97    )?;
98
99    let planner_text = extract_planner_text(&output.stdout).context(
100        "Task decomposition planner did not produce a final assistant response containing JSON.",
101    )?;
102    let raw = parse_planner_response(&planner_text)?;
103    let default_root_title = match &source {
104        DecompositionSource::Freeform { request } => request.clone(),
105        DecompositionSource::ExistingTask { task } => task.title.clone(),
106    };
107    let plan = normalize_response(raw, kind_for_source(&source), opts, &default_root_title)?;
108    let write_blockers = compute_write_blockers(
109        &active,
110        done.as_ref(),
111        &source,
112        attach_target.as_ref(),
113        opts.child_policy,
114    )?;
115
116    Ok(DecompositionPreview {
117        source,
118        attach_target,
119        plan,
120        write_blockers,
121        child_status: opts.status,
122        child_policy: opts.child_policy,
123        with_dependencies: opts.with_dependencies,
124    })
125}
126
127fn build_planner_prompt(
128    resolved: &config::Resolved,
129    opts: &TaskDecomposeOptions,
130    source: &DecompositionSource,
131    attach_target: Option<&DecompositionAttachTarget>,
132    template: &str,
133) -> Result<String> {
134    let (source_mode, source_request, source_task_json) = match source {
135        DecompositionSource::Freeform { request } => ("freeform", request.clone(), String::new()),
136        DecompositionSource::ExistingTask { task } => (
137            "existing_task",
138            task.request.clone().unwrap_or_else(|| task.title.clone()),
139            serde_json::to_string_pretty(task)
140                .context("serialize source task for decomposition")?,
141        ),
142    };
143    let attach_target_json = attach_target
144        .map(|target| {
145            serde_json::to_string_pretty(&target.task)
146                .context("serialize attach target for decomposition")
147        })
148        .transpose()?
149        .unwrap_or_default();
150    let project_type = resolved.config.project_type.unwrap_or(ProjectType::Code);
151    let mut prompt = prompts::render_task_decompose_prompt(
152        template,
153        source_mode,
154        &source_request,
155        &source_task_json,
156        &attach_target_json,
157        opts.max_depth,
158        opts.max_children,
159        opts.max_nodes,
160        opts.child_policy,
161        opts.with_dependencies,
162        project_type,
163        &resolved.config,
164    )?;
165    prompt = prompts::wrap_with_repoprompt_requirement(&prompt, opts.repoprompt_tool_injection);
166    prompts::wrap_with_instruction_files(&resolved.repo_root, &prompt, &resolved.config)
167}
168
169fn extract_planner_text(stdout: &str) -> Option<String> {
170    runner::extract_final_assistant_response(stdout).or_else(|| {
171        let trimmed = stdout.trim();
172        if trimmed.starts_with('{') && trimmed.ends_with('}') {
173            Some(trimmed.to_string())
174        } else {
175            None
176        }
177    })
178}
179
180fn parse_planner_response(raw_text: &str) -> Result<RawDecompositionResponse> {
181    let stripped = strip_code_fences(raw_text.trim());
182    serde_json::from_str::<RawDecompositionResponse>(stripped)
183        .or_else(|_| match extract_json_object(stripped) {
184            Some(candidate) => serde_json::from_str::<RawDecompositionResponse>(&candidate),
185            None => Err(serde_json::Error::io(std::io::Error::new(
186                std::io::ErrorKind::InvalidData,
187                "no JSON object found in planner response",
188            ))),
189        })
190        .context("parse task decomposition planner JSON")
191}
192
193fn strip_code_fences(raw: &str) -> &str {
194    let trimmed = raw.trim();
195    if let Some(inner) = trimmed.strip_prefix("```")
196        && let Some(end) = inner.rfind("```")
197    {
198        let body = &inner[..end];
199        if let Some(after_language) = body.find('\n') {
200            return body[after_language + 1..].trim();
201        }
202        return body.trim();
203    }
204    trimmed
205}
206
207fn extract_json_object(raw: &str) -> Option<String> {
208    let start = raw.find('{')?;
209    let end = raw.rfind('}')?;
210    (start < end).then(|| raw[start..=end].to_string())
211}