Skip to main content

gid_core/
working_mem.rs

1//! Working Memory — context for code changes
2//!
3//! Provides GID-based context about edited files and their impact.
4//! Used by agents to understand the blast radius of their changes.
5
6use crate::graph::{Graph, Node, Edge};
7use std::collections::HashSet;
8
9// ═══ Data Structures ═══
10
11/// GID-provided structural data about edited nodes.
12#[derive(Debug, Clone, Default)]
13pub struct GidContext {
14    /// Nodes that were touched/modified
15    pub nodes_touched: Vec<NodeInfo>,
16    /// Maximum number of callers for any touched node
17    pub max_callers: usize,
18    /// Total blast radius (sum of all callers)
19    pub total_blast_radius: usize,
20    /// Hub nodes (high connectivity)
21    pub hub_nodes: Vec<NodeInfo>,
22}
23
24/// Info about a single code node.
25#[derive(Debug, Clone)]
26pub struct NodeInfo {
27    pub id: String,
28    pub name: String,
29    pub file: String,
30    pub kind: String,
31    pub callers: usize,
32    pub callees: usize,
33    pub line: Option<usize>,
34}
35
36impl NodeInfo {
37    pub fn from_node(node: &Node, callers: usize, callees: usize) -> Self {
38        let kind = match node.node_kind.as_deref() {
39            Some("File") => "file",
40            Some("Class") | Some("Interface") | Some("Enum") | Some("TypeAlias") | Some("Trait") => "class",
41            Some("Function") | Some("Constant") | Some("Method") => "function",
42            Some("Module") => "module",
43            _ => "unknown",
44        };
45        Self {
46            id: node.id.clone(),
47            name: node.title.clone(),
48            file: node.file_path.as_deref().unwrap_or("").to_string(),
49            kind: kind.to_string(),
50            callers,
51            callees,
52            line: node.start_line,
53        }
54    }
55
56    /// Backwards-compatible alias (deprecated — use from_node).
57    pub fn from_code_node(node: &Node, callers: usize, callees: usize) -> Self {
58        Self::from_node(node, callers, callees)
59    }
60}
61
62/// Test outcome classification.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum ErrorType {
65    Syntax,
66    Import,
67    Attribute,
68    Assertion,
69    Type,
70    Name,
71    Runtime,
72    Timeout,
73    Unknown,
74}
75
76impl std::fmt::Display for ErrorType {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        match self {
79            ErrorType::Syntax => write!(f, "SyntaxError"),
80            ErrorType::Import => write!(f, "ImportError"),
81            ErrorType::Attribute => write!(f, "AttributeError"),
82            ErrorType::Assertion => write!(f, "AssertionError"),
83            ErrorType::Type => write!(f, "TypeError"),
84            ErrorType::Name => write!(f, "NameError"),
85            ErrorType::Runtime => write!(f, "RuntimeError"),
86            ErrorType::Timeout => write!(f, "Timeout"),
87            ErrorType::Unknown => write!(f, "Unknown"),
88        }
89    }
90}
91
92// ═══ Helper functions for Graph-based lookups ═══
93
94/// Count callers (incoming "calls" edges) for a node in the code layer.
95fn count_callers(node_id: &str, code_edges: &[&Edge]) -> usize {
96    code_edges.iter()
97        .filter(|e| e.to == node_id && e.relation == "calls")
98        .count()
99}
100
101/// Count callees (outgoing "calls" edges) for a node in the code layer.
102fn count_callees(node_id: &str, code_edges: &[&Edge]) -> usize {
103    code_edges.iter()
104        .filter(|e| e.from == node_id && e.relation == "calls")
105        .count()
106}
107
108/// Collect nodes impacted by a change (transitive incoming dependents).
109fn collect_impacted_nodes<'a>(
110    node_id: &str,
111    code_edges: &[&Edge],
112    graph: &'a Graph,
113    visited: &mut HashSet<String>,
114    result: &mut Vec<&'a Node>,
115) {
116    if !visited.insert(node_id.to_string()) {
117        return;
118    }
119    for edge in code_edges.iter().filter(|e| e.to == node_id) {
120        if let Some(node) = graph.get_node(&edge.from) {
121            result.push(node);
122            collect_impacted_nodes(&edge.from, code_edges, graph, visited, result);
123        }
124    }
125}
126
127/// Collect impacted nodes with optional relation filter.
128fn collect_impacted_nodes_filtered<'a>(
129    node_id: &str,
130    code_edges: &[&Edge],
131    graph: &'a Graph,
132    relations: Option<&[&str]>,
133    visited: &mut HashSet<String>,
134    result: &mut Vec<&'a Node>,
135) {
136    if !visited.insert(node_id.to_string()) {
137        return;
138    }
139    for edge in code_edges.iter().filter(|e| e.to == node_id) {
140        if let Some(rels) = relations {
141            if !rels.contains(&edge.relation.as_str()) {
142                continue;
143            }
144        }
145        if let Some(node) = graph.get_node(&edge.from) {
146            result.push(node);
147            collect_impacted_nodes_filtered(&edge.from, code_edges, graph, relations, visited, result);
148        }
149    }
150}
151
152// ═══ Context Queries ═══
153
154/// Query GID context for changed files.
155/// Returns structural data about the nodes in those files.
156pub fn query_gid_context(files_changed: &[String], graph: &Graph) -> GidContext {
157    let code_nodes = graph.code_nodes();
158    let code_edges = graph.code_edges();
159    let mut nodes = Vec::new();
160    let mut max_callers = 0;
161    let mut total_blast = 0;
162
163    for file in files_changed {
164        // Find all function/class nodes in this file
165        let file_nodes: Vec<&&Node> = code_nodes.iter()
166            .filter(|n| {
167                let fp = n.file_path.as_deref().unwrap_or("");
168                let is_test = n.metadata.get("is_test").and_then(|v| v.as_bool()).unwrap_or(false);
169                let is_func_or_class = matches!(
170                    n.node_kind.as_deref(),
171                    Some("Function") | Some("Method") | Some("Class")
172                );
173                fp == file.as_str() && !is_test && is_func_or_class
174            })
175            .collect();
176
177        for node in file_nodes {
178            let callers = count_callers(&node.id, &code_edges);
179            let callees = count_callees(&node.id, &code_edges);
180
181            max_callers = max_callers.max(callers);
182            total_blast += callers;
183
184            nodes.push(NodeInfo::from_node(node, callers, callees));
185        }
186    }
187
188    // Sort by caller count descending, keep top 10
189    nodes.sort_by(|a, b| b.callers.cmp(&a.callers));
190    nodes.truncate(10);
191
192    // Identify hub nodes (high connectivity)
193    let hub_threshold = 10;
194    let hub_nodes: Vec<NodeInfo> = nodes.iter()
195        .filter(|n| n.callers >= hub_threshold)
196        .cloned()
197        .collect();
198
199    GidContext {
200        nodes_touched: nodes,
201        max_callers,
202        total_blast_radius: total_blast,
203        hub_nodes,
204    }
205}
206
207/// Find low-coupling alternative nodes near the failed files.
208/// Called after high-coupling failures to suggest safer edit targets.
209pub fn find_low_risk_alternatives(
210    graph: &Graph,
211    failed_files: &[String],
212    max_callers: usize,
213) -> Vec<NodeInfo> {
214    let code_nodes = graph.code_nodes();
215    let code_edges = graph.code_edges();
216    let mut alternatives = Vec::new();
217
218    // Find packages containing failed files
219    let packages: HashSet<String> = failed_files.iter()
220        .filter_map(|f| {
221            f.rsplitn(2, '/').nth(1).map(|s| s.to_string())
222        })
223        .collect();
224
225    for node in &code_nodes {
226        let is_test = node.metadata.get("is_test").and_then(|v| v.as_bool()).unwrap_or(false);
227        if is_test {
228            continue;
229        }
230        if node.node_kind.as_deref() != Some("Function") {
231            continue;
232        }
233
234        let fp = node.file_path.as_deref().unwrap_or("");
235
236        // Must be in a related package
237        let in_package = packages.iter().any(|pkg| fp.starts_with(pkg));
238        if !in_package {
239            continue;
240        }
241
242        // Must not be in the same files we already tried
243        if failed_files.iter().any(|f| f == fp) {
244            continue;
245        }
246
247        let callers = count_callers(&node.id, &code_edges);
248        if callers <= max_callers {
249            let callees = count_callees(&node.id, &code_edges);
250            alternatives.push(NodeInfo::from_node(node, callers, callees));
251        }
252    }
253
254    // Sort by caller count ascending (safest first)
255    alternatives.sort_by_key(|n| n.callers);
256    alternatives.truncate(5);
257    alternatives
258}
259
260/// Classify error type from raw test output.
261pub fn classify_error(raw_output: &str) -> ErrorType {
262    let checks: &[(ErrorType, &[&str])] = &[
263        (ErrorType::Syntax, &["SyntaxError:", "SyntaxError("]),
264        (ErrorType::Import, &["ImportError:", "ModuleNotFoundError:"]),
265        (ErrorType::Attribute, &["AttributeError:"]),
266        (ErrorType::Assertion, &["AssertionError:", "AssertionError(", "assert "]),
267        (ErrorType::Type, &["TypeError:"]),
268        (ErrorType::Name, &["NameError:"]),
269        (ErrorType::Timeout, &["TimeoutError", "timed out", "TIMEOUT"]),
270    ];
271
272    let mut best = ErrorType::Unknown;
273    let mut best_count = 0;
274
275    for (etype, patterns) in checks {
276        let count: usize = patterns.iter()
277            .map(|p| raw_output.matches(p).count())
278            .sum();
279        if count > best_count {
280            best_count = count;
281            best = etype.clone();
282        }
283    }
284
285    // SyntaxError is usually the root cause
286    if best != ErrorType::Syntax && raw_output.contains("SyntaxError:") {
287        return ErrorType::Syntax;
288    }
289
290    best
291}
292
293/// Extract the key traceback from test output.
294pub fn extract_key_traceback(raw_output: &str, max_chars: usize) -> String {
295    let traceback_marker = "Traceback (most recent call last)";
296
297    if let Some(pos) = raw_output.find(traceback_marker) {
298        let chunk = &raw_output[pos..];
299        let end = chunk.find("\n\n")
300            .or_else(|| chunk.find("\n====="))
301            .or_else(|| chunk.find("\nFAILED"))
302            .unwrap_or(chunk.len());
303        return chunk[..end.min(max_chars)].to_string();
304    }
305
306    // Fallback: look for FAILED/ERROR sections
307    for marker in &["FAIL:", "ERROR:", "FAILED "] {
308        if let Some(pos) = raw_output.find(marker) {
309            let start = pos.saturating_sub(200);
310            let end = (pos + max_chars).min(raw_output.len());
311            return raw_output[start..end].to_string();
312        }
313    }
314
315    // Last resort: tail of output
316    let start = raw_output.len().saturating_sub(max_chars);
317    raw_output[start..].to_string()
318}
319
320// ═══ Impact Analysis ═══
321
322/// Analyze what's affected by changing given files.
323#[derive(Debug, Clone)]
324pub struct ImpactAnalysis {
325    /// Source nodes directly or transitively affected
326    pub affected_source: Vec<NodeInfo>,
327    /// Test nodes that exercise the changed code
328    pub affected_tests: Vec<NodeInfo>,
329    /// Risk level (low, medium, high, critical)
330    pub risk_level: RiskLevel,
331    /// Human-readable summary
332    pub summary: String,
333}
334
335#[derive(Debug, Clone, PartialEq, Eq)]
336pub enum RiskLevel {
337    Low,      // < 5 callers
338    Medium,   // 5-20 callers
339    High,     // 20-50 callers
340    Critical, // > 50 callers
341}
342
343impl std::fmt::Display for RiskLevel {
344    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
345        match self {
346            RiskLevel::Low => write!(f, "low"),
347            RiskLevel::Medium => write!(f, "medium"),
348            RiskLevel::High => write!(f, "high"),
349            RiskLevel::Critical => write!(f, "critical"),
350        }
351    }
352}
353
354/// Analyze impact of changing files.
355pub fn analyze_impact(files_changed: &[String], graph: &Graph) -> ImpactAnalysis {
356    let gid_ctx = query_gid_context(files_changed, graph);
357    let code_edges = graph.code_edges();
358
359    let mut affected_source = Vec::new();
360    let mut affected_tests = Vec::new();
361    let mut seen = HashSet::new();
362
363    // Get all nodes in changed files (code layer)
364    let changed_node_ids: Vec<String> = graph.code_nodes().iter()
365        .filter(|n| {
366            let fp = n.file_path.as_deref().unwrap_or("");
367            files_changed.iter().any(|f| f == fp)
368        })
369        .map(|n| n.id.clone())
370        .collect();
371
372    // Find affected nodes (who calls/depends on changed nodes)
373    for node_id in &changed_node_ids {
374        let mut impacted = Vec::new();
375        let mut visited = HashSet::new();
376        collect_impacted_nodes(node_id, &code_edges, graph, &mut visited, &mut impacted);
377
378        for impacted_node in impacted {
379            if seen.insert(impacted_node.id.clone()) {
380                let callers = count_callers(&impacted_node.id, &code_edges);
381                let callees = count_callees(&impacted_node.id, &code_edges);
382                let is_test = impacted_node.metadata.get("is_test")
383                    .and_then(|v| v.as_bool()).unwrap_or(false);
384                let info = NodeInfo::from_node(impacted_node, callers, callees);
385
386                if is_test {
387                    affected_tests.push(info);
388                } else {
389                    affected_source.push(info);
390                }
391            }
392        }
393    }
394
395    // Determine risk level
396    let risk_level = match gid_ctx.max_callers {
397        0..=5 => RiskLevel::Low,
398        6..=20 => RiskLevel::Medium,
399        21..=50 => RiskLevel::High,
400        _ => RiskLevel::Critical,
401    };
402
403    // Build summary
404    let summary = format!(
405        "Changing {} file(s) affects {} source nodes and {} test nodes. Risk: {} (max {} callers, blast radius {}).",
406        files_changed.len(),
407        affected_source.len(),
408        affected_tests.len(),
409        risk_level,
410        gid_ctx.max_callers,
411        gid_ctx.total_blast_radius,
412    );
413
414    ImpactAnalysis {
415        affected_source,
416        affected_tests,
417        risk_level,
418        summary,
419    }
420}
421
422/// Analyze impact of changing files, with optional edge relation filter.
423pub fn analyze_impact_filtered(
424    files_changed: &[String],
425    graph: &Graph,
426    relations: Option<&[&str]>,
427) -> ImpactAnalysis {
428    let gid_ctx = query_gid_context(files_changed, graph);
429    let code_edges = graph.code_edges();
430
431    let mut affected_source = Vec::new();
432    let mut affected_tests = Vec::new();
433    let mut seen = HashSet::new();
434
435    let changed_node_ids: Vec<String> = graph.code_nodes().iter()
436        .filter(|n| {
437            let fp = n.file_path.as_deref().unwrap_or("");
438            files_changed.iter().any(|f| f == fp)
439        })
440        .map(|n| n.id.clone())
441        .collect();
442
443    for node_id in &changed_node_ids {
444        let mut impacted = Vec::new();
445        let mut visited = HashSet::new();
446        collect_impacted_nodes_filtered(node_id, &code_edges, graph, relations, &mut visited, &mut impacted);
447
448        for impacted_node in impacted {
449            if seen.insert(impacted_node.id.clone()) {
450                let callers = count_callers(&impacted_node.id, &code_edges);
451                let callees = count_callees(&impacted_node.id, &code_edges);
452                let is_test = impacted_node.metadata.get("is_test")
453                    .and_then(|v| v.as_bool()).unwrap_or(false);
454                let info = NodeInfo::from_node(impacted_node, callers, callees);
455
456                if is_test {
457                    affected_tests.push(info);
458                } else {
459                    affected_source.push(info);
460                }
461            }
462        }
463    }
464
465    let risk_level = match gid_ctx.max_callers {
466        0..=5 => RiskLevel::Low,
467        6..=20 => RiskLevel::Medium,
468        21..=50 => RiskLevel::High,
469        _ => RiskLevel::Critical,
470    };
471
472    let summary = format!(
473        "Changing {} file(s) affects {} source nodes and {} test nodes. Risk: {} (max {} callers, blast radius {}).",
474        files_changed.len(),
475        affected_source.len(),
476        affected_tests.len(),
477        risk_level,
478        gid_ctx.max_callers,
479        gid_ctx.total_blast_radius,
480    );
481
482    ImpactAnalysis {
483        affected_source,
484        affected_tests,
485        risk_level,
486        summary,
487    }
488}
489
490/// Format impact analysis for LLM consumption.
491pub fn format_impact_for_llm(analysis: &ImpactAnalysis) -> String {
492    let mut result = String::new();
493
494    result.push_str(&format!("## Impact Analysis\n\n{}\n\n", analysis.summary));
495
496    if !analysis.affected_source.is_empty() {
497        result.push_str("**Affected source code:**\n");
498        for node in analysis.affected_source.iter().take(10) {
499            result.push_str(&format!(
500                "- {} `{}` ({} callers)\n",
501                node.kind, node.name, node.callers
502            ));
503        }
504        if analysis.affected_source.len() > 10 {
505            result.push_str(&format!("  ...and {} more\n", analysis.affected_source.len() - 10));
506        }
507        result.push('\n');
508    }
509
510    if !analysis.affected_tests.is_empty() {
511        result.push_str("**Related tests:**\n");
512        for node in analysis.affected_tests.iter().take(10) {
513            result.push_str(&format!("- `{}` in {}\n", node.name, node.file));
514        }
515        if analysis.affected_tests.len() > 10 {
516            result.push_str(&format!("  ...and {} more\n", analysis.affected_tests.len() - 10));
517        }
518        result.push('\n');
519    }
520
521    if analysis.risk_level == RiskLevel::High || analysis.risk_level == RiskLevel::Critical {
522        result.push_str("⚠️ **High-risk change!** Consider:\n");
523        result.push_str("- Breaking the change into smaller pieces\n");
524        result.push_str("- Adding backward compatibility\n");
525        result.push_str("- Running full test suite before committing\n\n");
526    }
527
528    result
529}
530
531// ═══ Agent Working Memory ═══
532
533/// What action the agent took in a round.
534#[derive(Debug, Clone)]
535pub enum Action {
536    Edit { files: Vec<String>, applied: usize, total: usize },
537    Revert,
538    Read { file: String },
539    Search { pattern: String },
540    Query { kind: String, target: String },
541    Test,
542    Other(String),
543}
544
545impl std::fmt::Display for Action {
546    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
547        match self {
548            Action::Edit { files, applied, total } => {
549                let names: Vec<&str> = files.iter().map(|f| {
550                    f.rsplit('/').next().unwrap_or(f.as_str())
551                }).collect();
552                write!(f, "EDIT {} ({}/{})", names.join(", "), applied, total)
553            }
554            Action::Revert => write!(f, "REVERT"),
555            Action::Read { file } => write!(f, "READ {}", file.rsplit('/').next().unwrap_or(file)),
556            Action::Search { pattern } => {
557                let display = if pattern.len() > 30 {
558                    let mut end = 30;
559                    while end > 0 && !pattern.is_char_boundary(end) { end -= 1; }
560                    &pattern[..end]
561                } else {
562                    pattern.as_str()
563                };
564                write!(f, "SEARCH '{}'", display)
565            }
566            Action::Query { kind, target } => write!(f, "GID {} {}", kind, target),
567            Action::Test => write!(f, "TEST"),
568            Action::Other(s) => {
569                let display = if s.len() > 30 {
570                    let mut end = 30;
571                    while end > 0 && !s.is_char_boundary(end) { end -= 1; }
572                    &s[..end]
573                } else {
574                    s.as_str()
575                };
576                write!(f, "{}", display)
577            }
578        }
579    }
580}
581
582/// Test outcome with classified error type.
583#[derive(Debug, Clone)]
584pub struct TestOutcome {
585    /// Error type classified from output
586    pub error_type: ErrorType,
587    /// (passed, total) for primary test set
588    pub primary: (usize, usize),
589    /// (passed, total) for secondary/regression test set
590    pub secondary: (usize, usize),
591    /// Key traceback or error message
592    pub key_error_trace: String,
593    /// Names of failed secondary tests
594    pub failed_secondary_names: Vec<String>,
595}
596
597impl TestOutcome {
598    pub fn new(
599        error_type: ErrorType,
600        primary_passed: usize,
601        primary_total: usize,
602        secondary_passed: usize,
603        secondary_total: usize,
604    ) -> Self {
605        Self {
606            error_type,
607            primary: (primary_passed, primary_total),
608            secondary: (secondary_passed, secondary_total),
609            key_error_trace: String::new(),
610            failed_secondary_names: Vec::new(),
611        }
612    }
613
614    pub fn with_trace(mut self, trace: String) -> Self {
615        self.key_error_trace = trace;
616        self
617    }
618
619    pub fn with_failed_names(mut self, names: Vec<String>) -> Self {
620        self.failed_secondary_names = names;
621        self
622    }
623
624    /// Calculate a composite score. Higher is better.
625    /// Primary tests are weighted heavily; secondary regressions penalize.
626    pub fn score(&self) -> i32 {
627        let secondary_clean = if self.secondary.1 == 0 || self.secondary.0 == self.secondary.1 { 1 } else { 0 };
628        (self.primary.0 as i32) * 1000 * secondary_clean + self.secondary.0 as i32
629    }
630}
631
632/// One round's record in working memory.
633#[derive(Debug, Clone)]
634pub struct AttemptRecord {
635    pub round: usize,
636    pub action: Action,
637    pub gid_context: Option<GidContext>,
638    pub test_outcome: Option<TestOutcome>,
639    /// Immediate feedback text (edit result, read content, etc.)
640    pub feedback: String,
641}
642
643/// Accumulated risk data for a node.
644#[derive(Debug, Clone)]
645pub struct NodeRisk {
646    pub callers: usize,
647    pub times_tried: usize,
648    pub times_failed: usize,
649}
650
651/// The complete working state for an agent repair/task session.
652/// Generic — tracks what the agent has done, what worked, what failed.
653pub struct WorkingMemory {
654    pub attempts: Vec<AttemptRecord>,
655    pub node_risk_map: std::collections::HashMap<String, NodeRisk>,
656    pub best_score: i32,
657    pub best_attempt: Option<usize>,
658    /// Low-risk alternative nodes found by graph analysis (cached after high-coupling failure).
659    pub low_risk_alternatives: Vec<NodeInfo>,
660}
661
662impl Default for WorkingMemory {
663    fn default() -> Self {
664        Self::new()
665    }
666}
667
668impl WorkingMemory {
669    pub fn new() -> Self {
670        Self {
671            attempts: Vec::new(),
672            node_risk_map: std::collections::HashMap::new(),
673            best_score: -1,
674            best_attempt: None,
675            low_risk_alternatives: Vec::new(),
676        }
677    }
678
679    /// Record an EDIT action with GID context.
680    pub fn record_edit(
681        &mut self,
682        round: usize,
683        files: Vec<String>,
684        applied: usize,
685        total: usize,
686        gid_ctx: GidContext,
687        feedback: String,
688    ) {
689        self.attempts.push(AttemptRecord {
690            round,
691            action: Action::Edit { files, applied, total },
692            gid_context: Some(gid_ctx),
693            test_outcome: None,
694            feedback,
695        });
696    }
697
698    /// Record a TEST result. Updates best score and node risk map.
699    pub fn record_test(&mut self, round: usize, outcome: TestOutcome, raw_feedback: String) {
700        let score = outcome.score();
701
702        if score > self.best_score {
703            self.best_score = score;
704            self.best_attempt = Some(round);
705        }
706
707        // Update node risk map from the most recent EDIT's GID context
708        if let Some(last_edit) = self.attempts.iter().rev().find(|a| matches!(a.action, Action::Edit { .. })) {
709            if let Some(ref gid) = last_edit.gid_context {
710                for node in &gid.nodes_touched {
711                    let entry = self.node_risk_map.entry(node.name.clone()).or_insert(NodeRisk {
712                        callers: node.callers,
713                        times_tried: 0,
714                        times_failed: 0,
715                    });
716                    entry.times_tried += 1;
717                    if outcome.secondary.0 < outcome.secondary.1 || outcome.primary.0 < outcome.primary.1 {
718                        entry.times_failed += 1;
719                    }
720                }
721            }
722        }
723
724        self.attempts.push(AttemptRecord {
725            round,
726            action: Action::Test,
727            gid_context: None,
728            test_outcome: Some(outcome),
729            feedback: raw_feedback,
730        });
731    }
732
733    /// Record a non-test, non-edit action (READ, SEARCH, REVERT, query).
734    pub fn record_action(&mut self, round: usize, action: Action, feedback: String) {
735        self.attempts.push(AttemptRecord {
736            round,
737            action,
738            gid_context: None,
739            test_outcome: None,
740            feedback,
741        });
742    }
743
744    /// Project working memory to LLM-readable prompt text.
745    /// Provides structured data — facts, not conclusions.
746    pub fn project_to_prompt(&self) -> String {
747        let mut out = String::new();
748
749        // Section 1: Attempt history table
750        let test_attempts: Vec<&AttemptRecord> = self.attempts.iter()
751            .filter(|a| a.test_outcome.is_some())
752            .collect();
753
754        if !test_attempts.is_empty() {
755            out.push_str("## Attempt History\n\n");
756            out.push_str("| # | Target | Callers | Error | Primary | Secondary |\n");
757            out.push_str("|---|--------|---------|-------|---------|------------|\n");
758
759            for test_a in &test_attempts {
760                let t = test_a.test_outcome.as_ref().unwrap();
761
762                // Find the last EDIT before this TEST
763                let edit_info = self.attempts.iter()
764                    .filter(|a| a.round < test_a.round && matches!(a.action, Action::Edit { .. }))
765                    .last();
766
767                let (target, callers) = if let Some(edit) = edit_info {
768                    let target_str = match &edit.action {
769                        Action::Edit { files, .. } => {
770                            files.iter()
771                                .map(|f| f.rsplit('/').next().unwrap_or(f))
772                                .collect::<Vec<_>>()
773                                .join(", ")
774                        }
775                        _ => "-".into(),
776                    };
777                    let callers_str = edit.gid_context.as_ref()
778                        .map(|g| g.max_callers.to_string())
779                        .unwrap_or("-".into());
780                    (target_str, callers_str)
781                } else {
782                    ("-".into(), "-".into())
783                };
784
785                out.push_str(&format!(
786                    "| {} | {} | {} | {} | {}/{} | {}/{} |\n",
787                    test_a.round,
788                    target,
789                    callers,
790                    t.error_type,
791                    t.primary.0, t.primary.1,
792                    t.secondary.0, t.secondary.1,
793                ));
794            }
795            out.push('\n');
796        }
797
798        // Section 2: Node risk data
799        let mut risky: Vec<(&String, &NodeRisk)> = self.node_risk_map.iter()
800            .filter(|(_, r)| r.times_failed > 0)
801            .collect();
802        risky.sort_by(|a, b| b.1.callers.cmp(&a.1.callers));
803
804        if !risky.is_empty() {
805            out.push_str("## Node History\n");
806            for (name, risk) in risky.iter().take(10) {
807                out.push_str(&format!(
808                    "- {} — {} callers, tried {}, failed {}\n",
809                    name, risk.callers, risk.times_tried, risk.times_failed
810                ));
811            }
812            out.push('\n');
813        }
814
815        // Section 3: Low-risk alternatives
816        if !self.low_risk_alternatives.is_empty() {
817            out.push_str("## Low-Coupling Alternatives\n");
818            for alt in &self.low_risk_alternatives {
819                out.push_str(&format!(
820                    "- {} ({}) — {} callers\n",
821                    alt.name, alt.file.rsplit('/').next().unwrap_or(&alt.file), alt.callers
822                ));
823            }
824            out.push('\n');
825        }
826
827        // Section 4: Latest error detail
828        if let Some(last_test) = self.attempts.iter().rev().find(|a| a.test_outcome.is_some()) {
829            let t = last_test.test_outcome.as_ref().unwrap();
830            out.push_str(&format!("## Latest Error (Round {})\n", last_test.round));
831            out.push_str(&format!("Type: {}\n", t.error_type));
832            out.push_str(&format!("Primary: {}/{}, Secondary: {}/{}\n",
833                t.primary.0, t.primary.1, t.secondary.0, t.secondary.1));
834
835            if !t.key_error_trace.is_empty() {
836                out.push_str(&format!("\n```\n{}\n```\n", t.key_error_trace));
837            }
838
839            // Show failed secondary test names
840            if !t.failed_secondary_names.is_empty() {
841                let show: Vec<&str> = t.failed_secondary_names.iter().take(10).map(|s| s.as_str()).collect();
842                let remaining = t.failed_secondary_names.len().saturating_sub(10);
843                out.push_str(&format!("\nFailed: {}", show.join(", ")));
844                if remaining > 0 {
845                    out.push_str(&format!(" (+{} more)", remaining));
846                }
847                out.push('\n');
848            }
849        }
850
851        // Section 5: Best result
852        if let Some(best_round) = self.best_attempt {
853            out.push_str(&format!(
854                "\n## Best Result: Round {} (score {})\n",
855                best_round, self.best_score
856            ));
857        }
858
859        out
860    }
861
862    /// Get the last tool feedback for inclusion in the next prompt.
863    pub fn last_feedback(&self) -> &str {
864        self.attempts.last()
865            .map(|a| a.feedback.as_str())
866            .unwrap_or("")
867    }
868}
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873    use crate::graph::{Graph, Node, Edge, NodeStatus};
874
875    /// Helper to create a code node (source=extract).
876    fn make_code_node(id: &str, title: &str, file_path: &str, kind: &str, line: Option<usize>, is_test: bool) -> Node {
877        let mut node = Node::new(id, title);
878        node.source = Some("extract".to_string());
879        node.node_type = Some("code".to_string());
880        node.status = NodeStatus::Done;
881        node.file_path = Some(file_path.to_string());
882        node.node_kind = Some(kind.to_string());
883        node.start_line = line;
884        if is_test {
885            node.metadata.insert("is_test".to_string(), serde_json::json!(true));
886        }
887        node
888    }
889
890    /// Helper to create a code edge (source=extract in metadata).
891    fn make_code_edge(from: &str, to: &str, relation: &str) -> Edge {
892        let mut edge = Edge::new(from, to, relation);
893        edge.metadata = Some(serde_json::json!({"source": "extract"}));
894        edge
895    }
896
897    #[test]
898    fn test_classify_error() {
899        assert_eq!(classify_error("SyntaxError: invalid syntax"), ErrorType::Syntax);
900        assert_eq!(classify_error("ImportError: No module named 'foo'"), ErrorType::Import);
901        assert_eq!(classify_error("AssertionError: 1 != 2"), ErrorType::Assertion);
902    }
903
904    #[test]
905    fn test_classify_syntax_overrides() {
906        let output = "ImportError: ...\nSyntaxError: invalid syntax\nImportError: ...";
907        assert_eq!(classify_error(output), ErrorType::Syntax);
908    }
909
910    #[test]
911    fn test_risk_level() {
912        let mut graph = Graph::new();
913
914        // Create a function with many callers
915        graph.add_node(make_code_node(
916            "func:core.py:hot_func", "hot_func", "core.py", "Function", Some(10), false,
917        ));
918
919        // Add many callers
920        for i in 0..30 {
921            let caller_id = format!("func:caller{}.py:caller_{}", i, i);
922            graph.add_node(make_code_node(
923                &caller_id, &format!("caller_{}", i), &format!("caller{}.py", i), "Function", Some(1), false,
924            ));
925            graph.add_edge(make_code_edge(&caller_id, "func:core.py:hot_func", "calls"));
926        }
927
928        let analysis = analyze_impact(&["core.py".into()], &graph);
929        assert_eq!(analysis.risk_level, RiskLevel::High);
930    }
931
932    #[test]
933    fn test_extract_traceback() {
934        let output = r#"
935FAILED tests/test_foo.py::test_bar
936Traceback (most recent call last):
937  File "tests/test_foo.py", line 10, in test_bar
938    assert result == expected
939AssertionError: 1 != 2
940
941FAILED tests/test_other.py::test_baz
942"#;
943        let tb = extract_key_traceback(output, 500);
944        assert!(tb.contains("Traceback (most recent call last)"));
945        assert!(tb.contains("AssertionError: 1 != 2"));
946    }
947}