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