1use 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)]
20pub struct DecisionLoop {
22 lifecycle: ExecutionLifecycle,
23}
24
25#[derive(Debug, Clone)]
26pub struct DecisionResult {
28 pub task_id: String,
29 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 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}