Skip to main content

forge_agent/
loop.rs

1//! Agent loop orchestrator - Deterministic 6-phase execution.
2//!
3//! This module implements the core agent loop that sequences all phases:
4//! Observe -> Constrain -> Plan -> Mutate -> Verify -> Commit
5//!
6//! Each phase receives the output of the previous phase, and failures
7//! trigger rollback with audit trail entries.
8
9use crate::{
10    audit::AuditEvent, audit::AuditLog, policy::PolicyValidator, CommitResult, ConstrainedPlan,
11    ExecutionPlan, MutationResult, Observation, VerificationResult,
12};
13use chrono::Utc;
14use forge_core::Forge;
15use std::sync::Arc;
16
17/// Agent phase in the execution loop.
18#[derive(Clone, Debug, PartialEq)]
19pub enum AgentPhase {
20    /// Observation phase - gather context from graph
21    Observe,
22    /// Constraint phase - apply policy rules
23    Constrain,
24    /// Plan phase - generate execution steps
25    Plan,
26    /// Mutate phase - apply changes
27    Mutate,
28    /// Verify phase - validate results
29    Verify,
30    /// Commit phase - finalize transaction
31    Commit,
32}
33
34/// Result of a complete agent loop execution.
35#[derive(Clone, Debug)]
36pub struct LoopResult {
37    /// Transaction ID for this execution
38    pub transaction_id: String,
39    /// Files that were modified
40    pub modified_files: Vec<std::path::PathBuf>,
41    /// Audit trail of all phase transitions
42    pub audit_events: Vec<AuditEvent>,
43}
44
45/// Agent loop orchestrator.
46///
47/// The AgentLoop sequences all 6 phases of the agent execution,
48/// ensuring each phase receives the previous phase's output and
49/// failures trigger proper rollback.
50pub struct AgentLoop {
51    /// Forge SDK for graph queries
52    forge: Arc<Forge>,
53    /// Current transaction state
54    transaction: Option<crate::transaction::Transaction>,
55    /// Audit log for phase transitions
56    audit_log: AuditLog,
57}
58
59impl AgentLoop {
60    /// Creates a new agent loop with fresh state.
61    ///
62    /// # Arguments
63    ///
64    /// * `forge` - The Forge SDK instance for graph queries
65    pub fn new(forge: Arc<Forge>) -> Self {
66        Self {
67            forge,
68            transaction: None,
69            audit_log: AuditLog::new(),
70        }
71    }
72
73    /// Runs the full agent loop: Observe -> Constrain -> Plan -> Mutate -> Verify -> Commit
74    ///
75    /// # Arguments
76    ///
77    /// * `query` - The natural language query or request
78    ///
79    /// # Returns
80    ///
81    /// Returns `LoopResult` with commit data on success, or error on phase failure.
82    pub async fn run(&mut self, query: &str) -> Result<LoopResult, crate::AgentError> {
83        // Phase 1: Observe
84        let observation = match self.observe_phase(query).await {
85            Ok(obs) => obs,
86            Err(e) => {
87                self.record_rollback(&e).await;
88                return Err(e);
89            }
90        };
91
92        // Phase 2: Constrain
93        let constrained = match self.constrain_phase(observation).await {
94            Ok(constrained) => constrained,
95            Err(e) => {
96                self.record_rollback(&e).await;
97                return Err(e);
98            }
99        };
100
101        // Phase 3: Plan
102        let plan = match self.plan_phase(constrained).await {
103            Ok(plan) => plan,
104            Err(e) => {
105                self.record_rollback(&e).await;
106                return Err(e);
107            }
108        };
109
110        // Phase 4: Mutate
111        let mutation_result = match self.mutate_phase(plan).await {
112            Ok(result) => result,
113            Err(e) => {
114                self.record_rollback(&e).await;
115                return Err(e);
116            }
117        };
118
119        // Phase 5: Verify
120        let verification = match self.verify_phase(mutation_result).await {
121            Ok(verification) => verification,
122            Err(e) => {
123                self.record_rollback(&e).await;
124                return Err(e);
125            }
126        };
127
128        // Phase 6: Commit
129        let commit_result = match self.commit_phase(verification).await {
130            Ok(result) => result,
131            Err(e) => {
132                self.record_rollback(&e).await;
133                return Err(e);
134            }
135        };
136
137        // Success - return loop result
138        Ok(LoopResult {
139            transaction_id: commit_result.transaction_id,
140            modified_files: commit_result.files_committed,
141            audit_events: self.audit_log.clone().into_events(),
142        })
143    }
144
145    /// Observation phase - gather context from the graph.
146    async fn observe_phase(&mut self, query: &str) -> Result<Observation, crate::AgentError> {
147        // Use Observer to gather context
148        let observer = crate::observe::Observer::new((*self.forge).clone());
149        let observation = observer
150            .gather(query)
151            .await
152            .map_err(|e| crate::AgentError::ObservationFailed(e.to_string()))?;
153
154        let symbol_count = observation.symbols.len();
155
156        // Record audit event
157        self.audit_log
158            .record(AuditEvent::Observe {
159                timestamp: Utc::now(),
160                query: query.to_string(),
161                symbol_count,
162            })
163            .await
164            .map_err(|e| crate::AgentError::ObservationFailed(e.to_string()))?;
165
166        Ok(observation)
167    }
168
169    /// Constraint phase - apply policy rules.
170    async fn constrain_phase(
171        &mut self,
172        observation: Observation,
173    ) -> Result<ConstrainedPlan, crate::AgentError> {
174        // Create validator with empty policies for now
175        let validator = PolicyValidator::new((*self.forge).clone());
176        let diff = crate::policy::Diff {
177            file_path: std::path::PathBuf::from(""),
178            original: String::new(),
179            modified: String::new(),
180            changes: Vec::new(),
181        };
182        let policies = Vec::new(); // No policies for v0.3
183
184        let report = validator
185            .validate(&diff, &policies)
186            .await
187            .map_err(|e| crate::AgentError::PolicyViolation(e.to_string()))?;
188
189        let policy_count = policies.len();
190        let violations = report.violations.len();
191
192        // Record audit event
193        self.audit_log
194            .record(AuditEvent::Constrain {
195                timestamp: Utc::now(),
196                policy_count,
197                violations,
198            })
199            .await
200            .map_err(|e| crate::AgentError::PolicyViolation(e.to_string()))?;
201
202        Ok(ConstrainedPlan {
203            observation,
204            policy_violations: report.violations,
205        })
206    }
207
208    /// Plan phase - generate execution steps.
209    async fn plan_phase(
210        &mut self,
211        constrained: ConstrainedPlan,
212    ) -> Result<ExecutionPlan, crate::AgentError> {
213        // Create planner
214        let planner = crate::planner::Planner::new();
215
216        // Generate steps from observation
217        let steps = planner
218            .generate_steps(&constrained.observation)
219            .await
220            .map_err(|e| crate::AgentError::PlanningFailed(e.to_string()))?;
221
222        // Estimate impact
223        let impact = planner
224            .estimate_impact(&steps)
225            .await
226            .map_err(|e| crate::AgentError::PlanningFailed(e.to_string()))?;
227        let estimated_files = impact.affected_files.len();
228
229        // Detect conflicts
230        let conflicts = planner
231            .detect_conflicts(&steps)
232            .map_err(|e| crate::AgentError::PlanningFailed(e.to_string()))?;
233
234        if !conflicts.is_empty() {
235            return Err(crate::AgentError::PlanningFailed(format!(
236                "Found {} conflicts in plan",
237                conflicts.len()
238            )));
239        }
240
241        // Order steps
242        let mut ordered_steps = steps;
243        planner
244            .order_steps(&mut ordered_steps)
245            .map_err(|e| crate::AgentError::PlanningFailed(e.to_string()))?;
246
247        let step_count = ordered_steps.len();
248
249        // Generate rollback plan
250        let rollback = planner.generate_rollback(&ordered_steps);
251
252        // Record audit event
253        self.audit_log
254            .record(AuditEvent::Plan {
255                timestamp: Utc::now(),
256                step_count,
257                estimated_files,
258            })
259            .await
260            .map_err(|e| crate::AgentError::PlanningFailed(e.to_string()))?;
261
262        Ok(ExecutionPlan {
263            steps: ordered_steps,
264            estimated_impact: impact,
265            rollback_plan: rollback,
266        })
267    }
268
269    /// Mutate phase - apply changes.
270    async fn mutate_phase(
271        &mut self,
272        plan: ExecutionPlan,
273    ) -> Result<MutationResult, crate::AgentError> {
274        // Create mutator
275        let mut mutator = crate::mutate::Mutator::new();
276        mutator
277            .begin_transaction()
278            .await
279            .map_err(|e| crate::AgentError::MutationFailed(e.to_string()))?;
280
281        // Apply each step
282        for step in &plan.steps {
283            mutator
284                .apply_step(step)
285                .await
286                .map_err(|e| crate::AgentError::MutationFailed(e.to_string()))?;
287        }
288
289        // Transfer transaction to loop for commit/rollback
290        self.transaction = Some(mutator.into_transaction()?);
291
292        // Collect modified files (empty for v0.3)
293        let files_modified: Vec<String> = Vec::new();
294
295        // Record audit event
296        self.audit_log
297            .record(AuditEvent::Mutate {
298                timestamp: Utc::now(),
299                files_modified,
300            })
301            .await
302            .map_err(|e| crate::AgentError::MutationFailed(e.to_string()))?;
303
304        Ok(MutationResult {
305            modified_files: Vec::new(), // Will be populated in v0.4
306            diffs: vec!["Mutation applied".to_string()],
307        })
308    }
309
310    /// Verify phase - validate results.
311    async fn verify_phase(
312        &mut self,
313        _result: MutationResult,
314    ) -> Result<VerificationResult, crate::AgentError> {
315        // Create verifier
316        let verifier = crate::verify::Verifier::new();
317
318        // For now, use empty path (will be proper path in v0.4)
319        let report = verifier
320            .verify(std::path::Path::new(""))
321            .await
322            .map_err(|e| crate::AgentError::VerificationFailed(e.to_string()))?;
323
324        let diagnostic_count = report.diagnostics.len();
325        let passed = report.passed;
326
327        // Record audit event
328        self.audit_log
329            .record(AuditEvent::Verify {
330                timestamp: Utc::now(),
331                passed,
332                diagnostic_count,
333            })
334            .await
335            .map_err(|e| crate::AgentError::VerificationFailed(e.to_string()))?;
336
337        Ok(VerificationResult {
338            passed: report.passed,
339            diagnostics: report
340                .diagnostics
341                .iter()
342                .map(|d| d.message.clone())
343                .collect(),
344        })
345    }
346
347    /// Commit phase - finalize transaction.
348    async fn commit_phase(
349        &mut self,
350        verification: VerificationResult,
351    ) -> Result<CommitResult, crate::AgentError> {
352        // Extract files from diagnostics
353        let files: Vec<std::path::PathBuf> = verification
354            .diagnostics
355            .iter()
356            .filter_map(|d| {
357                d.split(':')
358                    .next()
359                    .map(|s| std::path::PathBuf::from(s.trim()))
360            })
361            .collect();
362
363        // Create committer
364        let committer = crate::commit::Committer::new();
365        let commit_report = committer
366            .finalize(std::path::Path::new(""), &files)
367            .await
368            .map_err(|e| crate::AgentError::CommitFailed(e.to_string()))?;
369
370        // Commit transaction
371        if let Some(txn) = self.transaction.take() {
372            txn.commit()
373                .await
374                .map_err(|e| crate::AgentError::CommitFailed(e.to_string()))?;
375        }
376
377        let transaction_id = commit_report.transaction_id.clone();
378
379        // Record audit event
380        self.audit_log
381            .record(AuditEvent::Commit {
382                timestamp: Utc::now(),
383                transaction_id,
384            })
385            .await
386            .map_err(|e| crate::AgentError::CommitFailed(e.to_string()))?;
387
388        Ok(CommitResult {
389            transaction_id: commit_report.transaction_id,
390            files_committed: commit_report.files_committed,
391        })
392    }
393
394    /// Records a rollback in the audit log.
395    async fn record_rollback(&mut self, error: &crate::AgentError) {
396        // Rollback transaction if active
397        if let Some(txn) = self.transaction.take() {
398            let _ = txn.rollback().await;
399        }
400
401        // Determine phase from error type
402        let phase = match error {
403            crate::AgentError::ObservationFailed(_) => "Observe",
404            crate::AgentError::PolicyViolation(_) => "Constrain",
405            crate::AgentError::PlanningFailed(_) => "Plan",
406            crate::AgentError::MutationFailed(_) => "Mutate",
407            crate::AgentError::VerificationFailed(_) => "Verify",
408            crate::AgentError::CommitFailed(_) => "Commit",
409            crate::AgentError::ForgeError(_) => "Forge",
410        };
411
412        // Record rollback event
413        let _ = self
414            .audit_log
415            .record(AuditEvent::Rollback {
416                timestamp: Utc::now(),
417                reason: error.to_string(),
418                phase: phase.to_string(),
419            })
420            .await;
421    }
422
423    /// Returns a reference to the audit log (for testing).
424    #[cfg(test)]
425    pub fn audit_log(&self) -> &AuditLog {
426        &self.audit_log
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use tempfile::TempDir;
434
435    async fn create_test_loop() -> (AgentLoop, TempDir) {
436        let temp_dir = TempDir::new().unwrap();
437        let forge = Forge::open(temp_dir.path()).await.unwrap();
438        let agent_loop = AgentLoop::new(Arc::new(forge));
439        (agent_loop, temp_dir)
440    }
441
442    #[tokio::test]
443    async fn test_agent_loop_creation() {
444        let temp_dir = TempDir::new().unwrap();
445        let forge = Forge::open(temp_dir.path()).await.unwrap();
446        let agent_loop = AgentLoop::new(Arc::new(forge));
447
448        // Should create with fresh state
449        assert!(agent_loop.transaction.is_none());
450        assert_eq!(agent_loop.audit_log().len(), 0);
451    }
452
453    #[tokio::test]
454    async fn test_agent_loop_successful_run() {
455        let temp_dir = TempDir::new().unwrap();
456        let forge = Forge::open(temp_dir.path()).await.unwrap();
457        let mut agent_loop = AgentLoop::new(Arc::new(forge));
458
459        let result = agent_loop.run("test query").await;
460
461        // Should succeed or fail with verification error (expected for v0.3)
462        // The loop completes all phases, verification may fail on empty dir
463        match result {
464            Ok(loop_result) => {
465                // Verify transaction ID exists
466                assert!(!loop_result.transaction_id.is_empty());
467                // Verify audit log has 6 phase events
468                assert_eq!(loop_result.audit_events.len(), 6);
469            }
470            Err(e) => {
471                // Verification failure is expected for empty temp directory
472                assert!(e.to_string().contains("Verification") || e.to_string().contains("verification"));
473            }
474        }
475    }
476
477    #[tokio::test]
478    async fn test_agent_loop_state_isolation() {
479        let temp_dir = TempDir::new().unwrap();
480        let forge = Forge::open(temp_dir.path()).await.unwrap();
481        let mut agent_loop = AgentLoop::new(Arc::new(forge));
482
483        // First run - may fail at verification, but should record phases
484        let result1 = agent_loop.run("first query").await;
485        let events1_count = match &result1 {
486            Ok(r) => r.audit_events.len(),
487            Err(_) => {
488                // Even on failure, audit log should have events
489                // For v0.3, verification fails, so we expect partial audit
490                agent_loop.audit_log().len()
491            }
492        };
493
494        // Second run should have fresh state
495        let result2 = agent_loop.run("second query").await;
496        let events2_count = match &result2 {
497            Ok(r) => r.audit_events.len(),
498            Err(_) => agent_loop.audit_log().len(),
499        };
500
501        // Both runs should have similar number of events
502        // (may differ due to verification timing)
503        assert!(events1_count > 0);
504        assert!(events2_count > 0);
505    }
506
507    #[tokio::test]
508    async fn test_phase_transitions_recorded() {
509        let temp_dir = TempDir::new().unwrap();
510        let forge = Forge::open(temp_dir.path()).await.unwrap();
511        let mut agent_loop = AgentLoop::new(Arc::new(forge));
512
513        let result = agent_loop.run("test query").await;
514
515        // Even on verification failure, phases should be recorded
516        let events = match result {
517            Ok(r) => r.audit_events,
518            Err(_) => agent_loop.audit_log().clone().into_events(),
519        };
520
521        // Should have at least 5 phase events (Observe, Constrain, Plan, Mutate, Verify)
522        // Commit may not be reached if verification fails
523        assert!(events.len() >= 5);
524
525        // Check phase order for first few events
526        assert!(matches!(events[0], AuditEvent::Observe { .. }));
527        assert!(matches!(events[1], AuditEvent::Constrain { .. }));
528        assert!(matches!(events[2], AuditEvent::Plan { .. }));
529        assert!(matches!(events[3], AuditEvent::Mutate { .. }));
530
531        // The 5th event could be Verify or Rollback (if verification failed)
532        // Both are valid for v0.3
533        let is_valid_fifth = matches!(events[4], AuditEvent::Verify { .. } | AuditEvent::Rollback { .. });
534        assert!(is_valid_fifth, "Expected Verify or Rollback at index 4, got: {:?}", events[4]);
535    }
536
537    #[tokio::test]
538    async fn test_agent_loop_returns_loop_result() {
539        let temp_dir = TempDir::new().unwrap();
540        let forge = Forge::open(temp_dir.path()).await.unwrap();
541        let mut agent_loop = AgentLoop::new(Arc::new(forge));
542
543        let result = agent_loop.run("test query").await;
544
545        // Result type check - either Ok with LoopResult or Err
546        match result {
547            Ok(loop_result) => {
548                assert!(!loop_result.transaction_id.is_empty());
549                assert!(loop_result.modified_files.is_empty()); // No files in v0.3
550                assert!(!loop_result.audit_events.is_empty());
551            }
552            Err(e) => {
553                // Verification error is expected for empty temp directory
554                assert!(e.to_string().contains("Verification") || e.to_string().contains("verification"));
555            }
556        }
557    }
558}