ralph/commands/task/decompose/
planning.rs1use 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}