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 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}