Skip to main content

tirea_agent_loop/runtime/loop_runner/
outcome.rs

1use super::*;
2use serde_json::{json, Value};
3
4/// Aggregated token usage for one loop run.
5#[derive(Debug, Clone, Default, PartialEq, Eq)]
6pub struct LoopUsage {
7    pub prompt_tokens: usize,
8    pub completion_tokens: usize,
9    pub total_tokens: usize,
10}
11
12/// Aggregated runtime metrics for one loop run.
13#[derive(Debug, Clone, Default, PartialEq, Eq)]
14pub struct LoopStats {
15    pub duration_ms: u64,
16    pub steps: usize,
17    pub llm_calls: usize,
18    pub llm_retries: usize,
19    pub tool_calls: usize,
20    pub tool_errors: usize,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub(super) enum LoopFailure {
25    Llm(String),
26    State(String),
27}
28
29/// Unified terminal state for loop execution.
30#[derive(Debug)]
31pub struct LoopOutcome {
32    pub run_ctx: crate::contracts::RunContext,
33    pub termination: TerminationReason,
34    pub response: Option<String>,
35    pub usage: LoopUsage,
36    pub stats: LoopStats,
37    #[allow(dead_code)]
38    pub(super) failure: Option<LoopFailure>,
39}
40
41impl LoopOutcome {
42    /// Build a `RunFinish.result` payload from the unified outcome.
43    pub fn run_finish_result(&self) -> Option<Value> {
44        if !matches!(self.termination, TerminationReason::NaturalEnd) {
45            return None;
46        }
47        self.response
48            .as_ref()
49            .filter(|s| !s.is_empty())
50            .map(|text| json!({ "response": text }))
51    }
52
53    /// Project unified outcome into stream `RunFinish` event.
54    pub fn to_run_finish_event(self, run_id: String) -> AgentEvent {
55        AgentEvent::RunFinish {
56            thread_id: self.run_ctx.thread_id().to_string(),
57            run_id,
58            result: self.run_finish_result(),
59            termination: self.termination,
60        }
61    }
62}
63
64/// Error type for agent loop operations.
65#[derive(Debug, thiserror::Error)]
66pub enum AgentLoopError {
67    #[error("LLM error: {0}")]
68    LlmError(String),
69    #[error("State error: {0}")]
70    StateError(String),
71    /// The agent loop terminated normally due to a stop condition.
72    ///
73    /// This is not an error but a structured stop with a reason. The run context
74    /// is included so callers can inspect final state.
75    #[error("Agent stopped: {reason:?}")]
76    Stopped {
77        run_ctx: Box<crate::contracts::RunContext>,
78        reason: StopReason,
79    },
80    /// Pending user interaction; execution should pause until the client responds.
81    ///
82    /// The returned run context includes any patches applied up to the point where the
83    /// interaction was requested (including persisting the pending interaction).
84    #[error("Pending interaction: {id} ({action})", id = interaction.id, action = interaction.action)]
85    PendingInteraction {
86        run_ctx: Box<crate::contracts::RunContext>,
87        interaction: Box<Interaction>,
88    },
89    /// External cancellation signal requested run termination.
90    #[error("Run cancelled")]
91    Cancelled {
92        run_ctx: Box<crate::contracts::RunContext>,
93    },
94}
95
96impl AgentLoopError {
97    /// Normalize loop errors into lifecycle termination semantics.
98    pub fn termination_reason(&self) -> TerminationReason {
99        match self {
100            Self::Stopped { reason, .. } => TerminationReason::Stopped(reason.clone()),
101            Self::Cancelled { .. } => TerminationReason::Cancelled,
102            Self::PendingInteraction { .. } => TerminationReason::PendingInteraction,
103            Self::LlmError(_) | Self::StateError(_) => TerminationReason::Error,
104        }
105    }
106}
107
108impl From<crate::contracts::runtime::ToolExecutorError> for AgentLoopError {
109    fn from(value: crate::contracts::runtime::ToolExecutorError) -> Self {
110        match value {
111            crate::contracts::runtime::ToolExecutorError::Cancelled { thread_id } => {
112                Self::Cancelled {
113                    run_ctx: Box::new(crate::contracts::RunContext::new(
114                        thread_id,
115                        serde_json::json!({}),
116                        vec![],
117                        crate::contracts::RunConfig::default(),
118                    )),
119                }
120            }
121            crate::contracts::runtime::ToolExecutorError::Failed { message } => {
122                Self::StateError(message)
123            }
124        }
125    }
126}
127
128/// Helper to create a tool map from an iterator of tools.
129pub fn tool_map<I, T>(tools: I) -> HashMap<String, Arc<dyn Tool>>
130where
131    I: IntoIterator<Item = T>,
132    T: Tool + 'static,
133{
134    tools
135        .into_iter()
136        .map(|t| {
137            let name = t.descriptor().id.clone();
138            (name, Arc::new(t) as Arc<dyn Tool>)
139        })
140        .collect()
141}
142
143/// Helper to create a tool map from Arc<dyn Tool>.
144pub fn tool_map_from_arc<I>(tools: I) -> HashMap<String, Arc<dyn Tool>>
145where
146    I: IntoIterator<Item = Arc<dyn Tool>>,
147{
148    tools
149        .into_iter()
150        .map(|t| (t.descriptor().id.clone(), t))
151        .collect()
152}