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::Mention;
13use crate::backends::{ExternalBackend, ExternalId, ExternalTask};
14use crate::cmd::CommandErrorContext;
15use crate::home;
16use crate::store;
17use crate::store::MemoryEntry;
18use crate::store::TaskStore;
19use futures::future::join_all;
20use std::collections::HashSet;
21use std::fmt::Write as _;
22use std::path::Path;
23use std::sync::Arc;
24use tokio::fs;
25use tokio::process::Command;
26
27/// All assembled context for a task execution.
28#[derive(Debug, Default)]
29pub struct TaskContext {
30    /// Task-specific context from previous runs
31    pub task_context: String,
32    /// Parent issue summary + sibling summaries
33    pub parent_context: String,
34    /// Project instructions (CLAUDE.md + AGENTS.md + README.md)
35    pub project_instructions: String,
36    /// Skills documentation for selected skills
37    pub skills_docs: String,
38    /// Repository file tree (git ls-files, capped at 200 lines)
39    pub repo_tree: String,
40    /// Git diff from base branch (only for retries)
41    pub git_diff: String,
42    /// Recent issue comments
43    pub issue_comments: String,
44    /// PR review context (for re-dispatching after review changes requested)
45    pub pr_review_context: String,
46    /// Memory from previous attempts (capped at last 3)
47    pub memory: Vec<MemoryEntry>,
48}
49
50/// Load task-specific context from context file.
51pub async fn load_task_context(task_id: &str) -> String {
52    let contexts_dir = home::contexts_dir().unwrap_or_default();
53
54    let path = contexts_dir.join(format!("task-{task_id}.md"));
55    fs::read_to_string(&path).await.unwrap_or_default()
56}
57
58/// Build parent task context for subtasks.
59pub async fn build_parent_context(
60    task: &ExternalTask,
61    backend: &dyn ExternalBackend,
62    store: &Option<Arc<TaskStore>>,
63    repo: &str,
64) -> String {
65    // Check if task has a parent via store
66    let parent_id = match store::opt_store_get_task(store, repo, &task.id.0)
67        .await
68        .and_then(|t| t.parent_id)
69    {
70        Some(id) => id.to_string(),
71        None => return String::new(),
72    };
73
74    let mut ctx = String::new();
75
76    // Get parent issue details
77    let Ok(parent) = backend.get_task(&ExternalId(parent_id.clone())).await else {
78        return String::new();
79    };
80
81    let _ = writeln!(
82        ctx,
83        "## Parent Task #{}\n\n**Title**: {}\n\n{}\n",
84        parent.id.0, parent.title, parent.body
85    );
86
87    // Get sibling tasks — fetch remote task objects and store summaries in parallel
88    if let Ok(siblings) = backend.get_sub_issues(&ExternalId(parent_id)).await {
89        // Filter out self early
90        let siblings: Vec<ExternalId> = siblings.into_iter().filter(|s| s.0 != task.id.0).collect();
91
92        if !siblings.is_empty() {
93            ctx.push_str("## Sibling Tasks\n\n");
94
95            // Fetch ExternalTask objects concurrently
96            let task_futs = siblings.iter().map(|id| backend.get_task(id));
97            let task_results = join_all(task_futs).await;
98
99            // Fetch store task records concurrently (may be None for missing tasks)
100            let store_futs = siblings
101                .iter()
102                .map(|id| store::opt_store_get_task(store, repo, &id.0));
103            let store_results = join_all(store_futs).await;
104
105            for (i, maybe_task_res) in task_results.into_iter().enumerate() {
106                if let Ok(sib) = maybe_task_res {
107                    let status = sib
108                        .labels
109                        .iter()
110                        .find(|l| l.starts_with("status:"))
111                        .map_or_else(|| "unknown".to_string(), |s| s.replace("status:", ""));
112                    let _ = writeln!(ctx, "- #{} [{}]: {}", sib.id.0, status, sib.title);
113
114                    // Include summary if available from the store result at same index
115                    if let Some(Some(task_rec)) = store_results.get(i) {
116                        let summary = &task_rec.summary;
117                        if !summary.is_empty() {
118                            let _ = writeln!(ctx, "  Summary: {summary}");
119                        }
120                    }
121                }
122            }
123
124            ctx.push('\n');
125        }
126    }
127
128    ctx
129}
130
131/// Build project instructions from CLAUDE.md, AGENTS.md, README.md.
132pub async fn build_project_instructions(project_dir: &Path) -> String {
133    let mut instructions = String::new();
134    let mut seen = HashSet::new();
135
136    for filename in &["CLAUDE.md", "AGENTS.md", "README.md"] {
137        let path = project_dir.join(filename);
138        // CLAUDE.md is often a symlink to AGENTS.md, reading both put the same file in every prompt twice
139        let Ok(real) = fs::canonicalize(&path).await else {
140            continue;
141        };
142
143        if !seen.insert(real) {
144            continue;
145        }
146
147        if let Ok(content) = fs::read_to_string(&path).await
148            && !content.is_empty()
149        {
150            let _ = writeln!(instructions, "## {filename}\n\n{content}\n");
151        }
152    }
153
154    instructions
155}
156
157/// Build skills documentation for selected skills.
158pub async fn build_skills_docs(selected_skills: &[String]) -> String {
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        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) = 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: &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    let id = ExternalId(task_id.to_string());
294    let all_comments = match backend.list_issue_comments(&id).await {
295        Ok(c) => c,
296        Err(e) => {
297            tracing::debug!(task_id, error = %e, "failed to fetch issue comments");
298            return String::new();
299        }
300    };
301
302    // Filter bot comments, then take the last `limit` entries
303    let human_comments: Vec<_> = all_comments
304        .into_iter()
305        .filter(|c| !is_bot_comment(c))
306        .collect();
307
308    let start = human_comments.len().saturating_sub(limit);
309    let mut formatted = String::new();
310    for comment in &human_comments[start..] {
311        let _ = writeln!(
312            formatted,
313            "**@{}** ({}):\n{}\n",
314            comment.author, comment.created_at, comment.body
315        );
316    }
317
318    formatted
319}
320
321/// Build memory context from previous attempts.
322/// Returns formatted memory context string and the raw memory entries.
323pub async fn build_memory_context(
324    task_id: &str,
325    store: &Option<Arc<TaskStore>>,
326    repo: &str,
327) -> (String, Vec<MemoryEntry>) {
328    const MAX_MEMORY_ENTRIES: usize = 3;
329
330    let memory = match store::get_recent_memory_result(store, repo, task_id, MAX_MEMORY_ENTRIES)
331        .await
332    {
333        Ok(Some(entries)) => entries,
334        Ok(None) => Vec::new(),
335        Err(e) => {
336            tracing::warn!(task_id, error = %e, "build_memory_context: failed to read memory from store — proceeding without prior learnings");
337            Vec::new()
338        }
339    };
340
341    if memory.is_empty() {
342        return (String::new(), vec![]);
343    }
344
345    let mut context = String::new();
346    context.push_str("## Previous Attempts Memory\n\n");
347    context.push_str("Learnings from previous task attempts:\n\n");
348
349    for entry in &memory {
350        let _ = write!(
351            context,
352            "### Attempt #{} (Agent: {})",
353            entry.attempt, entry.agent
354        );
355
356        if let Some(ref model) = entry.model {
357            let _ = write!(context, ", Model: {model}");
358        }
359        context.push('\n');
360
361        if !entry.approach.is_empty() {
362            let _ = writeln!(context, "**Approach**: {}", entry.approach);
363        }
364
365        if !entry.learnings.is_empty() {
366            context.push_str("**Key Learnings**:\n");
367            for learning in &entry.learnings {
368                let _ = writeln!(context, "- {learning}");
369            }
370        }
371
372        if let Some(ref error) = entry.error {
373            let _ = writeln!(context, "**Error**: {error}");
374        }
375
376        if !entry.files_modified.is_empty() {
377            let _ = writeln!(
378                context,
379                "**Files Modified**: {}",
380                entry.files_modified.join(", ")
381            );
382        }
383
384        context.push('\n');
385    }
386
387    (context, memory)
388}
389
390/// Load PR review context from store.
391pub async fn load_pr_review_context(
392    task_id: &str,
393    store: &Option<Arc<TaskStore>>,
394    repo: &str,
395) -> String {
396    store::opt_store_get_task(store, repo, task_id)
397        .await
398        .map(|t| t.pr_review_context)
399        .unwrap_or_default()
400}
401
402/// Build the full task context.
403#[expect(
404    clippy::too_many_arguments,
405    reason = "each argument is a separate context source"
406)]
407pub async fn build_full_context(
408    task: &ExternalTask,
409    backend: Option<&dyn ExternalBackend>,
410    project_dir: &Path,
411    default_branch: &str,
412    attempts: u32,
413    selected_skills: &[String],
414    store: &Option<Arc<TaskStore>>,
415    repo: &str,
416) -> TaskContext {
417    let (
418        task_context,
419        parent_context,
420        project_instructions,
421        skills_docs,
422        repo_tree,
423        git_diff,
424        issue_comments,
425        pr_review_context,
426        (_, memory),
427    ) = tokio::join!(
428        load_task_context(&task.id.0),
429        async {
430            if let Some(b) = backend {
431                build_parent_context(task, b, store, repo).await
432            } else {
433                String::new()
434            }
435        },
436        build_project_instructions(project_dir),
437        build_skills_docs(selected_skills),
438        build_repo_tree(project_dir),
439        async {
440            if attempts > 0 {
441                build_git_diff(project_dir, default_branch).await
442            } else {
443                String::new()
444            }
445        },
446        async {
447            if let Some(b) = backend {
448                fetch_issue_comments(b, &task.id.0, 10).await
449            } else {
450                String::new()
451            }
452        },
453        load_pr_review_context(&task.id.0, store, repo),
454        build_memory_context(&task.id.0, store, repo),
455    );
456
457    TaskContext {
458        task_context,
459        parent_context,
460        project_instructions,
461        skills_docs,
462        repo_tree,
463        git_diff,
464        issue_comments,
465        pr_review_context,
466        memory,
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use std::env;
473    #[cfg(unix)]
474    use std::fs as std_fs;
475    #[cfg(unix)]
476    use std::os::unix::fs as unix_fs;
477
478    use super::*;
479
480    #[tokio::test]
481    async fn test_build_memory_context_empty() {
482        let (context, memory) = build_memory_context("nonexistent-task-12345", &None, "").await;
483        assert_eq!(context, "");
484        assert!(memory.is_empty(), "{memory:?}");
485    }
486
487    #[tokio::test]
488    async fn test_build_project_instructions_empty_dir() {
489        let dir = env::temp_dir().join("orch_test_nonexistent");
490        let instructions = build_project_instructions(&dir).await;
491        assert_eq!(instructions, "");
492    }
493
494    #[cfg(unix)]
495    #[tokio::test]
496    async fn project_instructions_read_a_symlinked_file_once() {
497        let dir = tempfile::tempdir().unwrap();
498        std_fs::write(dir.path().join("AGENTS.md"), "agent rules").unwrap();
499        unix_fs::symlink("AGENTS.md", dir.path().join("CLAUDE.md")).unwrap();
500        std_fs::write(dir.path().join("README.md"), "readme").unwrap();
501
502        let instructions = build_project_instructions(dir.path()).await;
503        assert_eq!(instructions.matches("agent rules").count(), 1);
504        assert!(instructions.contains("## CLAUDE.md"));
505        assert!(!instructions.contains("## AGENTS.md"));
506        assert!(instructions.contains("readme"));
507    }
508
509    #[tokio::test]
510    async fn test_build_skills_docs_empty() {
511        let docs = build_skills_docs(&[]).await;
512        assert_eq!(docs, "");
513    }
514
515    #[test]
516    fn test_task_context_default_values() {
517        let ctx = TaskContext {
518            task_context: String::new(),
519            parent_context: String::new(),
520            project_instructions: String::new(),
521            skills_docs: String::new(),
522            repo_tree: String::new(),
523            git_diff: String::new(),
524            issue_comments: String::new(),
525            pr_review_context: String::new(),
526            memory: vec![],
527        };
528
529        assert_eq!(ctx.task_context, "");
530        assert!(ctx.memory.is_empty(), "{:?}", ctx.memory);
531    }
532}