Skip to main content

orch/engine/runner/
context.rs

1//! Context building for task execution.
2//!
3//! Assembles all contextual information needed for the agent prompt:
4//! - Task-specific context files
5//! - Parent task context (for subtasks)
6//! - Project instructions (CLAUDE.md, AGENTS.md, README.md)
7//! - Skills documentation
8//! - Repository file tree
9//! - Git diff (for retries)
10//! - Issue comments
11
12use crate::backends::{ExternalBackend, ExternalId, ExternalTask};
13use crate::cmd::CommandErrorContext;
14use crate::store;
15use crate::store::TaskStore;
16use futures::future::join_all;
17use std::path::Path;
18use std::sync::Arc;
19use tokio::process::Command;
20
21/// All assembled context for a task execution.
22pub struct TaskContext {
23    /// Task-specific context from previous runs
24    pub task_context: String,
25    /// Parent issue summary + sibling summaries
26    pub parent_context: String,
27    /// Project instructions (CLAUDE.md + AGENTS.md + README.md)
28    pub project_instructions: String,
29    /// Skills documentation for selected skills
30    pub skills_docs: String,
31    /// Repository file tree (git ls-files, capped at 200 lines)
32    pub repo_tree: String,
33    /// Git diff from base branch (only for retries)
34    pub git_diff: String,
35    /// Recent issue comments
36    pub issue_comments: String,
37    /// PR review context (for re-dispatching after review changes requested)
38    pub pr_review_context: String,
39    /// Memory from previous attempts (capped at last 3)
40    pub memory: Vec<crate::store::MemoryEntry>,
41}
42
43/// Load task-specific context from context file.
44pub async fn load_task_context(task_id: &str) -> String {
45    let contexts_dir = crate::home::contexts_dir().unwrap_or_default();
46
47    let path = contexts_dir.join(format!("task-{task_id}.md"));
48    tokio::fs::read_to_string(&path).await.unwrap_or_default()
49}
50
51/// Build parent task context for subtasks.
52pub async fn build_parent_context(
53    task: &ExternalTask,
54    backend: &dyn ExternalBackend,
55    store: &Option<Arc<TaskStore>>,
56    repo: &str,
57) -> String {
58    // Check if task has a parent via store
59    let parent_id = match store::opt_store_get_task(store, repo, &task.id.0)
60        .await
61        .and_then(|t| t.parent_id)
62    {
63        Some(id) => id.to_string(),
64        None => return String::new(),
65    };
66
67    let mut ctx = String::new();
68
69    // Get parent issue details
70    let parent = match backend.get_task(&ExternalId(parent_id.clone())).await {
71        Ok(t) => t,
72        Err(_) => return String::new(),
73    };
74
75    ctx.push_str(&format!(
76        "## Parent Task #{}\n\n**Title**: {}\n\n{}\n\n",
77        parent.id.0, parent.title, parent.body
78    ));
79
80    // Get sibling tasks — fetch remote task objects and store summaries in parallel
81    if let Ok(siblings) = backend.get_sub_issues(&ExternalId(parent_id)).await {
82        // Filter out self early
83        let siblings: Vec<ExternalId> = siblings.into_iter().filter(|s| s.0 != task.id.0).collect();
84
85        if !siblings.is_empty() {
86            ctx.push_str("## Sibling Tasks\n\n");
87
88            // Fetch ExternalTask objects concurrently
89            let task_futs = siblings.iter().map(|id| backend.get_task(id));
90            let task_results = join_all(task_futs).await;
91
92            // Fetch store task records concurrently (may be None for missing tasks)
93            let store_futs = siblings
94                .iter()
95                .map(|id| store::opt_store_get_task(store, repo, &id.0));
96            let store_results = join_all(store_futs).await;
97
98            for (i, maybe_task_res) in task_results.into_iter().enumerate() {
99                if let Ok(sib) = maybe_task_res {
100                    let status = sib
101                        .labels
102                        .iter()
103                        .find(|l| l.starts_with("status:"))
104                        .map(|s| s.replace("status:", ""))
105                        .unwrap_or_else(|| "unknown".to_string());
106                    ctx.push_str(&format!("- #{} [{}]: {}\n", sib.id.0, status, sib.title));
107
108                    // Include summary if available from the store result at same index
109                    if let Some(Some(task_rec)) = store_results.get(i) {
110                        let summary = &task_rec.summary;
111                        if !summary.is_empty() {
112                            ctx.push_str(&format!("  Summary: {}\n", summary));
113                        }
114                    }
115                }
116            }
117
118            ctx.push('\n');
119        }
120    }
121
122    ctx
123}
124
125/// Build project instructions from CLAUDE.md, AGENTS.md, README.md.
126pub async fn build_project_instructions(project_dir: &Path) -> String {
127    let mut instructions = String::new();
128
129    for filename in &["CLAUDE.md", "AGENTS.md", "README.md"] {
130        let path = project_dir.join(filename);
131        if let Ok(content) = tokio::fs::read_to_string(&path).await {
132            if !content.is_empty() {
133                instructions.push_str(&format!("## {filename}\n\n{content}\n\n"));
134            }
135        }
136    }
137
138    instructions
139}
140
141/// Build skills documentation for selected skills.
142pub async fn build_skills_docs(selected_skills: &[String]) -> String {
143    if selected_skills.is_empty() {
144        return String::new();
145    }
146
147    let mut docs = String::new();
148    let skills_dirs = [
149        dirs::home_dir()
150            .unwrap_or_default()
151            .join(".claude")
152            .join("skills"),
153        crate::home::skills_dir().unwrap_or_default(),
154    ];
155
156    for skill in selected_skills {
157        for dir in &skills_dirs {
158            let skill_file = dir.join(skill).join("SKILL.md");
159            if let Ok(content) = tokio::fs::read_to_string(&skill_file).await {
160                docs.push_str(&format!("## Skill: {skill}\n\n{content}\n\n"));
161                break;
162            }
163        }
164    }
165
166    docs
167}
168
169/// Build repository file tree (git ls-files, capped at 200 lines).
170pub async fn build_repo_tree(project_dir: &Path) -> String {
171    let output = Command::new("git")
172        .args(["ls-files"])
173        .current_dir(project_dir)
174        .output_with_context()
175        .await;
176
177    match output {
178        Ok(o) if o.status.success() => {
179            let full = String::from_utf8_lossy(&o.stdout);
180            let all_lines: Vec<&str> = full.lines().collect();
181            let total = all_lines.len();
182            if total > 200 {
183                format!(
184                    "{}\n... (truncated, {} total files)",
185                    all_lines[..200].join("\n"),
186                    total
187                )
188            } else {
189                all_lines.join("\n")
190            }
191        }
192        _ => String::new(),
193    }
194}
195
196/// Build git diff from base branch (only for retries).
197pub async fn build_git_diff(project_dir: &Path, default_branch: &str) -> String {
198    let output = Command::new("git")
199        .args(["diff", &format!("origin/{default_branch}...HEAD")])
200        .current_dir(project_dir)
201        .output_with_context()
202        .await;
203
204    match output {
205        Ok(o) if o.status.success() => {
206            let diff = String::from_utf8_lossy(&o.stdout);
207            // Cap diff at 10000 chars to avoid blowing up context
208            if diff.len() > 10000 {
209                // Find safe UTF-8 boundary
210                let mut boundary = 10000;
211                while !diff.is_char_boundary(boundary) {
212                    boundary -= 1;
213                }
214                format!("{}...\n(diff truncated at 10000 chars)", &diff[..boundary])
215            } else {
216                diff.to_string()
217            }
218        }
219        _ => String::new(),
220    }
221}
222
223/// Build git log from base branch to HEAD.
224/// Shows commit history for the feature branch.
225pub async fn build_git_log(project_dir: &Path, default_branch: &str) -> String {
226    let output = Command::new("git")
227        .args([
228            "log",
229            "--oneline",
230            &format!("origin/{default_branch}..HEAD"),
231        ])
232        .current_dir(project_dir)
233        .output_with_context()
234        .await;
235
236    match output {
237        Ok(o) if o.status.success() => {
238            let log = String::from_utf8_lossy(&o.stdout);
239            let all_lines: Vec<&str> = log.lines().collect();
240            let total = all_lines.len();
241            if total > 100 {
242                format!(
243                    "{}\n... (truncated, {} total commits)",
244                    all_lines[..100].join("\n"),
245                    total
246                )
247            } else {
248                all_lines.join("\n")
249            }
250        }
251        _ => String::new(),
252    }
253}
254
255/// Returns true if the comment is from an automated bot and should be skipped.
256fn is_bot_comment(mention: &crate::backends::Mention) -> bool {
257    // Skip GitHub Actions bots and other automated accounts
258    if mention.author.ends_with("[bot]") {
259        return true;
260    }
261    // Skip automated review comments posted by orch itself
262    if mention.body.starts_with("## Automated Review") {
263        return true;
264    }
265    false
266}
267
268/// Fetch issue comments for agent context.
269///
270/// Fetches all comments for the specific issue using the per-issue API endpoint,
271/// then returns the last `limit` non-bot comments formatted for the prompt.
272pub async fn fetch_issue_comments(
273    backend: &dyn ExternalBackend,
274    task_id: &str,
275    limit: usize,
276) -> String {
277    let id = crate::backends::ExternalId(task_id.to_string());
278    let all_comments = match backend.list_issue_comments(&id).await {
279        Ok(c) => c,
280        Err(e) => {
281            tracing::debug!(task_id, error = %e, "failed to fetch issue comments");
282            return String::new();
283        }
284    };
285
286    // Filter bot comments, then take the last `limit` entries
287    let human_comments: Vec<_> = all_comments
288        .into_iter()
289        .filter(|c| !is_bot_comment(c))
290        .collect();
291
292    let start = human_comments.len().saturating_sub(limit);
293    let mut formatted = String::new();
294    for comment in &human_comments[start..] {
295        formatted.push_str(&format!(
296            "**@{}** ({}):\n{}\n\n",
297            comment.author, comment.created_at, comment.body
298        ));
299    }
300
301    formatted
302}
303
304/// Build memory context from previous attempts.
305/// Returns formatted memory context string and the raw memory entries.
306pub async fn build_memory_context(
307    task_id: &str,
308    store: &Option<Arc<TaskStore>>,
309    repo: &str,
310) -> (String, Vec<crate::store::MemoryEntry>) {
311    const MAX_MEMORY_ENTRIES: usize = 3;
312
313    let memory = match store::get_recent_memory_result(store, repo, task_id, MAX_MEMORY_ENTRIES)
314        .await
315    {
316        Ok(Some(entries)) => entries,
317        Ok(None) => Vec::new(),
318        Err(e) => {
319            tracing::warn!(task_id, error = %e, "build_memory_context: failed to read memory from store — proceeding without prior learnings");
320            Vec::new()
321        }
322    };
323
324    if memory.is_empty() {
325        return (String::new(), vec![]);
326    }
327
328    let mut context = String::new();
329    context.push_str("## Previous Attempts Memory\n\n");
330    context.push_str("Learnings from previous task attempts:\n\n");
331
332    for entry in &memory {
333        context.push_str(&format!(
334            "### Attempt #{} (Agent: {})",
335            entry.attempt, entry.agent
336        ));
337
338        if let Some(ref model) = entry.model {
339            context.push_str(&format!(", Model: {}", model));
340        }
341        context.push('\n');
342
343        if !entry.approach.is_empty() {
344            context.push_str(&format!("**Approach**: {}\n", entry.approach));
345        }
346
347        if !entry.learnings.is_empty() {
348            context.push_str("**Key Learnings**:\n");
349            for learning in &entry.learnings {
350                context.push_str(&format!("- {}\n", learning));
351            }
352        }
353
354        if let Some(ref error) = entry.error {
355            context.push_str(&format!("**Error**: {}\n", error));
356        }
357
358        if !entry.files_modified.is_empty() {
359            context.push_str(&format!(
360                "**Files Modified**: {}\n",
361                entry.files_modified.join(", ")
362            ));
363        }
364
365        context.push('\n');
366    }
367
368    (context, memory)
369}
370
371/// Load PR review context from store.
372pub async fn load_pr_review_context(
373    task_id: &str,
374    store: &Option<Arc<TaskStore>>,
375    repo: &str,
376) -> String {
377    store::opt_store_get_task(store, repo, task_id)
378        .await
379        .map(|t| t.pr_review_context)
380        .unwrap_or_default()
381}
382
383/// Build the full task context.
384#[allow(clippy::too_many_arguments)]
385pub async fn build_full_context(
386    task: &ExternalTask,
387    backend: Option<&dyn ExternalBackend>,
388    project_dir: &Path,
389    default_branch: &str,
390    attempts: u32,
391    selected_skills: &[String],
392    store: &Option<Arc<TaskStore>>,
393    repo: &str,
394) -> TaskContext {
395    let (
396        task_context,
397        parent_context,
398        project_instructions,
399        skills_docs,
400        repo_tree,
401        git_diff,
402        issue_comments,
403        pr_review_context,
404        (_, memory),
405    ) = tokio::join!(
406        load_task_context(&task.id.0),
407        async {
408            if let Some(b) = backend {
409                build_parent_context(task, b, store, repo).await
410            } else {
411                String::new()
412            }
413        },
414        build_project_instructions(project_dir),
415        build_skills_docs(selected_skills),
416        build_repo_tree(project_dir),
417        async {
418            if attempts > 0 {
419                build_git_diff(project_dir, default_branch).await
420            } else {
421                String::new()
422            }
423        },
424        async {
425            if let Some(b) = backend {
426                fetch_issue_comments(b, &task.id.0, 10).await
427            } else {
428                String::new()
429            }
430        },
431        load_pr_review_context(&task.id.0, store, repo),
432        build_memory_context(&task.id.0, store, repo),
433    );
434
435    TaskContext {
436        task_context,
437        parent_context,
438        project_instructions,
439        skills_docs,
440        repo_tree,
441        git_diff,
442        issue_comments,
443        pr_review_context,
444        memory,
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[tokio::test]
453    async fn test_build_memory_context_empty() {
454        let (context, memory) = build_memory_context("nonexistent-task-12345", &None, "").await;
455        assert!(context.is_empty());
456        assert!(memory.is_empty());
457    }
458
459    #[tokio::test]
460    async fn test_build_project_instructions_empty_dir() {
461        let dir = std::env::temp_dir().join("orch_test_nonexistent");
462        let instructions = build_project_instructions(&dir).await;
463        assert!(instructions.is_empty());
464    }
465
466    #[tokio::test]
467    async fn test_build_skills_docs_empty() {
468        let docs = build_skills_docs(&[]).await;
469        assert!(docs.is_empty());
470    }
471
472    #[test]
473    fn test_task_context_default_values() {
474        let ctx = TaskContext {
475            task_context: String::new(),
476            parent_context: String::new(),
477            project_instructions: String::new(),
478            skills_docs: String::new(),
479            repo_tree: String::new(),
480            git_diff: String::new(),
481            issue_comments: String::new(),
482            pr_review_context: String::new(),
483            memory: vec![],
484        };
485
486        assert!(ctx.task_context.is_empty());
487        assert!(ctx.memory.is_empty());
488    }
489}