Skip to main content

lean_ctx/core/
decision_loop_runtime.rs

1//! Compatibility adapter from MCP events to the canonical execution lifecycle.
2
3use std::{
4    panic::{AssertUnwindSafe, catch_unwind},
5    sync::OnceLock,
6};
7
8use crate::core::execution_lifecycle::{
9    CompletionObservation, ExecutionLifecycle, ProductEntitlements, RuntimeContext, ToolRequest,
10    ToolSurface,
11};
12
13#[cfg(any(test, feature = "test-support"))]
14use crate::core::triage::TriageEngine;
15
16pub use crate::core::execution_lifecycle::TaskContext;
17
18#[derive(Debug)]
19/// Bridges MCP tool lifecycle events into decision-loop accounting.
20pub struct DecisionLoopRuntime {
21    lifecycle: ExecutionLifecycle,
22}
23
24impl DecisionLoopRuntime {
25    pub fn get_or_init() -> &'static Self {
26        static RUNTIME: OnceLock<DecisionLoopRuntime> = OnceLock::new();
27        RUNTIME.get_or_init(|| Self {
28            lifecycle: ExecutionLifecycle::global().clone(),
29        })
30    }
31
32    /// Returns the most recently triaged profile for a session.
33    pub fn profile_for_session(
34        &self,
35        session_id: &str,
36    ) -> Option<crate::core::triage::profile::TaskProfileLocal> {
37        self.lifecycle.profile_for_session(session_id)
38    }
39
40    pub fn on_tool_start(
41        &self,
42        tool_name: &str,
43        query: Option<&str>,
44        session_id: &str,
45        agent_id: &str,
46    ) -> TaskContext {
47        catch_unwind(AssertUnwindSafe(|| {
48            self.lifecycle.begin(
49                ToolRequest {
50                    tool_name: tool_name.to_owned(),
51                    query: query.map(str::to_owned),
52                    session_id: session_id.to_owned(),
53                    agent_id: agent_id.to_owned(),
54                    surface: ToolSurface::Mcp,
55                    idempotency_key: None,
56                },
57                RuntimeContext::default(),
58                ProductEntitlements::default(),
59            )
60        }))
61        .unwrap_or_else(|_| {
62            self.lifecycle.begin(
63                ToolRequest {
64                    tool_name: tool_name.to_owned(),
65                    query: None,
66                    session_id: session_id.to_owned(),
67                    agent_id: agent_id.to_owned(),
68                    surface: ToolSurface::Mcp,
69                    idempotency_key: None,
70                },
71                RuntimeContext::default(),
72                ProductEntitlements::default(),
73            )
74        })
75    }
76
77    pub fn on_tool_end(
78        &self,
79        ctx: &TaskContext,
80        input_tokens: u64,
81        output_tokens: u64,
82        model: &str,
83        success: bool,
84    ) -> Option<crate::core::value_gate::ValueAssessment> {
85        if ctx.outcome().is_some() {
86            return None;
87        }
88        catch_unwind(AssertUnwindSafe(|| {
89            self.lifecycle
90                .complete_once(
91                    ctx,
92                    CompletionObservation::tool_result(input_tokens, output_tokens, model, success),
93                )
94                .and_then(|outcome| outcome.assessment)
95        }))
96        .ok()
97        .flatten()
98    }
99
100    #[cfg(any(test, feature = "test-support"))]
101    pub fn with_triage(triage: TriageEngine) -> Self {
102        Self {
103            lifecycle: ExecutionLifecycle::with_triage(triage),
104        }
105    }
106
107    #[cfg(any(test, feature = "test-support"))]
108    pub fn assessment_for(
109        &self,
110        task_id: &str,
111    ) -> Option<crate::core::value_gate::ValueAssessment> {
112        self.lifecycle.assessment_for(task_id)
113    }
114
115    #[cfg(any(test, feature = "test-support"))]
116    pub fn outcome_for(
117        &self,
118        task_id: &str,
119    ) -> Option<crate::core::execution_lifecycle::LifecycleOutcome> {
120        self.lifecycle.outcome_for(task_id)
121    }
122}