Skip to main content

lean_ctx/core/
decision_loop.rs

1//! End-to-end wiring from task triage through execution-value assessment.
2
3use lean_ctx_protocol::{
4    AcceptanceState, RiskClass, TaskComplexity, TaskEnvelopeV1, TaskProfileV1, TaskScope,
5};
6
7use crate::core::{
8    execution_lifecycle::{
9        CompletionObservation, ExecutionLifecycle, LifecycleStage, ProductEntitlements,
10        RuntimeContext, TaskContext, ToolRequest, ToolSurface,
11    },
12    triage::{
13        TriageEngine,
14        profile::{TaskProfileLocal, TaskScopeLocal},
15    },
16    value_gate::{self, ExecutionCost, OutcomeSignal, TaskOutcome, ValueAssessment, ValueGate},
17};
18
19#[derive(Debug, Clone)]
20/// Orchestrates triage, spine, and value gate for a complete task evaluation.
21pub struct DecisionLoop {
22    lifecycle: ExecutionLifecycle,
23}
24
25#[derive(Debug, Clone)]
26/// Holds the task state and value assessment from a decision-loop execution.
27pub struct DecisionResult {
28    pub task_id: String,
29    /// The ingress envelope enriched by triage; its task id is the lineage root.
30    pub envelope: TaskEnvelopeV1,
31    pub profile: TaskProfileLocal,
32    pub envelope_created: bool,
33    pub cost: Option<ExecutionCost>,
34    pub outcome: Option<TaskOutcome>,
35    pub assessment: Option<ValueAssessment>,
36    pub acceptance_state: AcceptanceState,
37    lifecycle_context: TaskContext,
38}
39
40impl Default for DecisionLoop {
41    fn default() -> Self {
42        Self::new(TriageEngine::default(), ValueGate)
43    }
44}
45
46impl DecisionLoop {
47    pub fn new(triage: TriageEngine, value_gate: ValueGate) -> Self {
48        Self {
49            lifecycle: ExecutionLifecycle::with_components(triage, value_gate),
50        }
51    }
52
53    pub fn execute_task(&self, query: &str, session_id: &str, agent_id: &str) -> DecisionResult {
54        let lifecycle_context = self.lifecycle.begin(
55            ToolRequest {
56                tool_name: "decision_loop".to_owned(),
57                query: Some(query.to_owned()),
58                session_id: session_id.to_owned(),
59                agent_id: agent_id.to_owned(),
60                surface: ToolSurface::Cli,
61                idempotency_key: None,
62            },
63            RuntimeContext::default(),
64            ProductEntitlements::default(),
65        );
66        lifecycle_context.advance_through(LifecycleStage::GatherContextStrategy);
67        let _ = lifecycle_context.skip(LifecycleStage::AskAutopilot, "not entitled");
68        let profile = lifecycle_context.profile.clone();
69        let envelope = lifecycle_context.envelope.clone();
70
71        DecisionResult {
72            task_id: envelope.task_id.as_str().to_owned(),
73            envelope,
74            profile,
75            envelope_created: true,
76            cost: None,
77            outcome: None,
78            assessment: None,
79            acceptance_state: AcceptanceState::Unknown,
80            lifecycle_context,
81        }
82    }
83
84    pub fn complete_task(
85        &self,
86        result: &mut DecisionResult,
87        cost: ExecutionCost,
88        signals: Vec<OutcomeSignal>,
89    ) {
90        let success = !signals.iter().any(|signal| {
91            matches!(
92                signal,
93                OutcomeSignal::CompileError | OutcomeSignal::TestFailed
94            )
95        });
96        let outcome = TaskOutcome {
97            task_id: result.task_id.clone(),
98            completed: true,
99            signals: signals.clone(),
100        };
101        let lifecycle_outcome = self.lifecycle.complete(
102            &result.lifecycle_context,
103            CompletionObservation {
104                input_tokens: cost.input_tokens,
105                output_tokens: cost.output_tokens,
106                model: cost.model.clone(),
107                provider: cost.provider.clone(),
108                success,
109                outcome_signals: signals,
110                proxy_economics: None,
111                heatmap: None,
112            },
113        );
114        result.cost = Some(cost);
115        result.outcome = Some(outcome);
116        result.acceptance_state = lifecycle_outcome.accepted_outcome.accepted;
117        result.assessment = lifecycle_outcome.assessment;
118    }
119
120    /// Fixture-only completion: never finalizes the production lifecycle or
121    /// records value/causal evidence. Its output must be labeled simulated.
122    pub fn complete_simulated_task(
123        &self,
124        result: &mut DecisionResult,
125        cost: ExecutionCost,
126        signals: Vec<OutcomeSignal>,
127    ) {
128        let outcome = TaskOutcome {
129            task_id: result.task_id.clone(),
130            completed: true,
131            signals,
132        };
133        result.acceptance_state = crate::core::execution_lifecycle::evaluate_outcome_signals(
134            &result.task_id,
135            &result.profile.intent,
136            &outcome.signals,
137        )
138        .accepted;
139        let assessment = (result.acceptance_state != AcceptanceState::Unknown
140            && !outcome.signals.is_empty())
141        .then(|| value_gate::assess_simulated_task(&result.task_id, &cost, &outcome));
142        result.cost = Some(cost);
143        result.outcome = Some(outcome);
144        result.assessment = assessment;
145    }
146
147    pub fn aggregate_cpao(results: &[DecisionResult]) -> Option<u64> {
148        let (costs, accepted): (Vec<_>, Vec<_>) = results
149            .iter()
150            .filter_map(|result| {
151                Some((
152                    result.cost.as_ref()?.estimated_cost_micros,
153                    result.assessment.as_ref()?.outcome_accepted,
154                ))
155            })
156            .unzip();
157        value_gate::cpao::cost_per_accepted_outcome(&costs, &accepted)
158    }
159}
160
161pub fn protocol_profile(profile: &TaskProfileLocal) -> TaskProfileV1 {
162    TaskProfileV1 {
163        primary_intent: profile.intent.clone(),
164        task_class: profile.task_class.clone(),
165        complexity: match profile.complexity.as_str() {
166            "medium" => TaskComplexity::Medium,
167            "high" => TaskComplexity::High,
168            _ => TaskComplexity::Low,
169        },
170        scope: match profile.scope {
171            TaskScopeLocal::SingleFile => TaskScope::SingleFile,
172            TaskScopeLocal::MultiFile => TaskScope::MultiFile,
173            TaskScopeLocal::CrossModule => TaskScope::CrossModule,
174            TaskScopeLocal::CrossProject => TaskScope::CrossProject,
175        },
176        context_need_milli: profile.context_need_milli,
177        reasoning_need_milli: profile.reasoning_need_milli,
178        risk_signal: match profile.risk_signal_milli {
179            750.. => RiskClass::High,
180            400.. => RiskClass::Medium,
181            _ => RiskClass::Low,
182        },
183        confidence_milli: profile.confidence_milli,
184        capability_id: None,
185        capability_version: None,
186        keywords: Vec::new(),
187        language_hints: Vec::new(),
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::core::{task_spine::TaskSpine, value_gate::cost_tracker::calculate_cost};
195
196    fn cost() -> ExecutionCost {
197        ExecutionCost {
198            input_tokens: 1_000,
199            output_tokens: 500,
200            cache_read_tokens: 0,
201            model: "gpt-4o".into(),
202            provider: "openai".into(),
203            estimated_cost_micros: calculate_cost(1_000, 500, 0, "gpt-4o"),
204        }
205    }
206
207    #[test]
208    fn legacy_decision_loop_does_not_invent_an_autopilot_decision() {
209        use crate::core::execution_lifecycle::{LIFECYCLE_STAGE_ORDER, StageDisposition};
210
211        let loop_ = DecisionLoop::default();
212        let mut result = loop_.execute_task("inspect lifecycle", "session", "agent");
213        assert_eq!(
214            result.lifecycle_context.completed_stages(),
215            LIFECYCLE_STAGE_ORDER[..6]
216        );
217        assert_eq!(
218            result.lifecycle_context.stage_executions()[5].disposition,
219            StageDisposition::Skipped("not entitled")
220        );
221        loop_.complete_task(&mut result, cost(), Vec::new());
222        assert_eq!(
223            result.lifecycle_context.completed_stages(),
224            LIFECYCLE_STAGE_ORDER
225        );
226        assert_eq!(
227            result.lifecycle_context.stage_executions()[5].disposition,
228            StageDisposition::Skipped("not entitled")
229        );
230        assert_eq!(result.acceptance_state, AcceptanceState::Unknown);
231    }
232
233    #[test]
234    fn test_full_decision_loop() {
235        let _isolation = crate::core::data_dir::isolated_data_dir();
236        let loop_ = DecisionLoop::default();
237        let mut result = loop_.execute_task("fix bug in auth.rs", "session", "agent");
238        loop_.complete_task(
239            &mut result,
240            cost(),
241            vec![OutcomeSignal::BuildSucceeded, OutcomeSignal::TestsPassed],
242        );
243
244        assert_eq!(result.profile.intent, "coding_fix");
245        assert!(result.envelope_created);
246        assert_eq!(result.envelope.task_id.as_str(), result.task_id);
247        assert_eq!(
248            TaskSpine::task_id().as_deref(),
249            Some(result.task_id.as_str())
250        );
251        assert_eq!(result.cost.as_ref().unwrap().input_tokens, 1_000);
252        assert_eq!(result.cost.as_ref().unwrap().output_tokens, 500);
253        assert_eq!(result.cost.as_ref().unwrap().model, "gpt-4o");
254        assert!(result.assessment.unwrap().cpao_micros.is_some());
255    }
256
257    #[test]
258    fn test_rejected_outcome() {
259        let _isolation = crate::core::data_dir::isolated_data_dir();
260        let loop_ = DecisionLoop::default();
261        let mut result = loop_.execute_task("fix bug in auth.rs", "session", "agent");
262        loop_.complete_task(&mut result, cost(), vec![OutcomeSignal::TestFailed]);
263
264        assert!(!result.assessment.as_ref().unwrap().outcome_accepted);
265        assert_eq!(result.assessment.unwrap().cpao_micros, None);
266    }
267
268    #[test]
269    fn test_unknown_query() {
270        let result = DecisionLoop::default().execute_task("", "session", "agent");
271
272        assert_eq!(
273            result.profile.confidence_milli,
274            crate::core::triage::confidence::RULES_FALLBACK_MILLI
275        );
276        assert!(result.envelope_created);
277    }
278
279    #[test]
280    fn test_multiple_tasks_cpao() {
281        let _isolation = crate::core::data_dir::isolated_data_dir();
282        let loop_ = DecisionLoop::default();
283        let mut results: Vec<_> = (0..3)
284            .map(|index| {
285                loop_.execute_task("fix bug in auth.rs", "session", &format!("agent-{index}"))
286            })
287            .collect();
288        for result in &mut results {
289            loop_.complete_task(
290                result,
291                cost(),
292                vec![OutcomeSignal::BuildSucceeded, OutcomeSignal::TestsPassed],
293            );
294        }
295
296        assert_eq!(
297            DecisionLoop::aggregate_cpao(&results),
298            Some(cost().estimated_cost_micros)
299        );
300    }
301}