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            settings: runutil::RunnerSettings {
59                repo_root: &resolved.repo_root,
60                runner_kind: settings.runner,
61                bins,
62                model: settings.model,
63                reasoning_effort: settings.reasoning_effort,
64                runner_cli: settings.runner_cli,
65                timeout: None,
66                permission_mode: settings.permission_mode,
67                output_handler: None,
68                output_stream: runner::OutputStream::Terminal,
69            },
70            execution: runutil::RunnerExecutionContext {
71                prompt: &prompt,
72                phase_type: PhaseType::SinglePhase,
73                session_id: None,
74            },
75            failure: runutil::RunnerFailureHandling {
76                revert_on_error: false,
77                git_revert_mode: resolved
78                    .config
79                    .agent
80                    .git_revert_mode
81                    .unwrap_or(crate::contracts::GitRevertMode::Ask),
82                revert_prompt: None,
83            },
84            retry: runutil::RunnerRetryState {
85                policy: retry_policy,
86            },
87        },
88        runutil::RunnerErrorMessages {
89            log_label: "task decompose planner",
90            interrupted_msg: "Task decomposition interrupted: the planner run was canceled.",
91            timeout_msg: "Task decomposition timed out before a plan was returned.",
92            terminated_msg: "Task decomposition terminated: the planner was stopped by a signal.",
93            non_zero_msg: |code| {
94                format!(
95                    "Task decomposition failed: the planner exited with a non-zero code ({code})."
96                )
97            },
98            other_msg: |err| {
99                format!(
100                    "Task decomposition failed: the planner could not be started or encountered an error. Error: {:#}",
101                    err
102                )
103            },
104        },
105    )?;
106
107    let planner_text = extract_planner_text(&output.stdout).context(
108        "Task decomposition planner did not produce a final assistant response containing JSON.",
109    )?;
110    let raw = parse_planner_response(&planner_text)?;
111    let default_root_title = match &source {
112        DecompositionSource::Freeform { request } => request.clone(),
113        DecompositionSource::ExistingTask { task } => task.title.clone(),
114    };
115    let plan = normalize_response(raw, kind_for_source(&source), opts, &default_root_title)?;
116    let write_blockers = compute_write_blockers(
117        &active,
118        done.as_ref(),
119        &source,
120        attach_target.as_ref(),
121        opts.child_policy,
122    )?;
123
124    Ok(DecompositionPreview {
125        source,
126        attach_target,
127        plan,
128        write_blockers,
129        child_status: opts.status,
130        child_policy: opts.child_policy,
131        with_dependencies: opts.with_dependencies,
132    })
133}
134
135fn build_planner_prompt(
136    resolved: &config::Resolved,
137    opts: &TaskDecomposeOptions,
138    source: &DecompositionSource,
139    attach_target: Option<&DecompositionAttachTarget>,
140    template: &str,
141) -> Result<String> {
142    let (source_mode, source_request, source_task_json) = match source {
143        DecompositionSource::Freeform { request } => ("freeform", request.clone(), String::new()),
144        DecompositionSource::ExistingTask { task } => (
145            "existing_task",
146            task.request.clone().unwrap_or_else(|| task.title.clone()),
147            serde_json::to_string_pretty(task)
148                .context("serialize source task for decomposition")?,
149        ),
150    };
151    let attach_target_json = attach_target
152        .map(|target| {
153            serde_json::to_string_pretty(&target.task)
154                .context("serialize attach target for decomposition")
155        })
156        .transpose()?
157        .unwrap_or_default();
158    let project_type = resolved.config.project_type.unwrap_or(ProjectType::Code);
159    let mut prompt = prompts::render_task_decompose_prompt(
160        template,
161        source_mode,
162        &source_request,
163        &source_task_json,
164        &attach_target_json,
165        opts.max_depth,
166        opts.max_children,
167        opts.max_nodes,
168        opts.child_policy,
169        opts.with_dependencies,
170        project_type,
171        &resolved.config,
172    )?;
173    prompt = prompts::wrap_with_repoprompt_requirement(&prompt, opts.repoprompt_tool_injection);
174    prompts::wrap_with_instruction_files(&resolved.repo_root, &prompt, &resolved.config)
175}
176
177fn extract_planner_text(stdout: &str) -> Option<String> {
178    runner::extract_final_assistant_response(stdout).or_else(|| {
179        let trimmed = stdout.trim();
180        if trimmed.starts_with('{') && trimmed.ends_with('}') {
181            Some(trimmed.to_string())
182        } else {
183            None
184        }
185    })
186}
187
188fn parse_planner_response(raw_text: &str) -> Result<RawDecompositionResponse> {
189    let stripped = strip_code_fences(raw_text.trim());
190    serde_json::from_str::<RawDecompositionResponse>(stripped)
191        .or_else(|_| match extract_json_object(stripped) {
192            Some(candidate) => serde_json::from_str::<RawDecompositionResponse>(&candidate),
193            None => Err(serde_json::Error::io(std::io::Error::new(
194                std::io::ErrorKind::InvalidData,
195                "no JSON object found in planner response",
196            ))),
197        })
198        .context("parse task decomposition planner JSON")
199}
200
201fn strip_code_fences(raw: &str) -> &str {
202    let trimmed = raw.trim();
203    if let Some(inner) = trimmed.strip_prefix("```")
204        && let Some(end) = inner.rfind("```")
205    {
206        let body = &inner[..end];
207        if let Some(after_language) = body.find('\n') {
208            return body[after_language + 1..].trim();
209        }
210        return body.trim();
211    }
212    trimmed
213}
214
215fn extract_json_object(raw: &str) -> Option<String> {
216    let start = raw.find('{')?;
217    let end = raw.rfind('}')?;
218    (start < end).then(|| raw[start..=end].to_string())
219}