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