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