Skip to main content

zagens_core/
turn.rs

1//! Turn types shared between the runtime core and the TUI shell.
2//!
3//! These types are pure data — no LLM client dependency, no IO.
4
5use std::time::Duration;
6
7/// Final status for a turn.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum TurnOutcomeStatus {
10    Completed,
11    Interrupted,
12    Failed,
13}
14
15/// Record of a tool call within a turn.
16#[derive(Debug, Clone)]
17pub struct TurnToolCall {
18    pub id: String,
19    pub name: String,
20    pub input: serde_json::Value,
21    pub result: Option<String>,
22    pub error: Option<String>,
23    pub duration: Option<Duration>,
24}
25
26impl TurnToolCall {
27    pub fn new(id: String, name: String, input: serde_json::Value) -> Self {
28        Self {
29            id,
30            name,
31            input,
32            result: None,
33            error: None,
34            duration: None,
35        }
36    }
37
38    pub fn set_result(&mut self, result: String, duration: Duration) {
39        self.result = Some(result);
40        self.duration = Some(duration);
41    }
42
43    pub fn set_error(&mut self, error: String, duration: Duration) {
44        self.error = Some(error);
45        self.duration = Some(duration);
46    }
47}
48
49/// Application mode slice used by the turn loop (mirrors TUI `AppMode`).
50#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum TurnLoopMode {
53    Agent,
54    Yolo,
55    Plan,
56}
57
58impl TurnLoopMode {
59    #[must_use]
60    pub const fn is_plan(self) -> bool {
61        matches!(self, Self::Plan)
62    }
63
64    /// Parse a runtime mode string (`"agent"` / `"yolo"` / `"plan"`).
65    /// Mirrors `tui::AppMode::from_setting` so HTTP/runtime callers can
66    /// stay shell-agnostic. Unknown values fall back to `Agent` to match
67    /// the tui-side contract.
68    #[must_use]
69    pub fn from_setting(value: &str) -> Self {
70        match value.trim().to_ascii_lowercase().as_str() {
71            "plan" => Self::Plan,
72            "yolo" => Self::Yolo,
73            _ => Self::Agent,
74        }
75    }
76}
77
78/// Context for a single turn (user message + AI response).
79#[derive(Debug)]
80pub struct TurnContext {
81    pub id: String,
82    pub step: u32,
83    pub max_steps: u32,
84    pub tool_calls: Vec<TurnToolCall>,
85    pub cancelled: bool,
86    pub usage: crate::models::Usage,
87}
88
89impl TurnContext {
90    #[must_use]
91    pub fn new(max_steps: u32) -> Self {
92        Self {
93            id: uuid::Uuid::new_v4().to_string(),
94            step: 0,
95            max_steps,
96            tool_calls: Vec::new(),
97            cancelled: false,
98            usage: crate::models::Usage::default(),
99        }
100    }
101
102    pub fn next_step(&mut self) -> bool {
103        self.step += 1;
104        self.step <= self.max_steps
105    }
106
107    #[must_use]
108    pub fn at_max_steps(&self) -> bool {
109        self.step >= self.max_steps
110    }
111
112    /// Remaining assistant steps before `max_steps` (inclusive of current step).
113    #[must_use]
114    pub fn steps_remaining(&self) -> u32 {
115        self.max_steps.saturating_sub(self.step)
116    }
117
118    pub fn record_tool_call(&mut self, call: TurnToolCall) {
119        self.tool_calls.push(call);
120    }
121
122    pub fn cancel(&mut self) {
123        self.cancelled = true;
124    }
125
126    pub fn add_usage(&mut self, usage: &crate::models::Usage) {
127        self.usage.input_tokens += usage.input_tokens;
128        self.usage.output_tokens += usage.output_tokens;
129        self.usage.prompt_cache_hit_tokens = add_optional_usage(
130            self.usage.prompt_cache_hit_tokens,
131            usage.prompt_cache_hit_tokens,
132        );
133        self.usage.prompt_cache_miss_tokens = add_optional_usage(
134            self.usage.prompt_cache_miss_tokens,
135            usage.prompt_cache_miss_tokens,
136        );
137        self.usage.reasoning_tokens =
138            add_optional_usage(self.usage.reasoning_tokens, usage.reasoning_tokens);
139    }
140}
141
142fn add_optional_usage(total: Option<u32>, delta: Option<u32>) -> Option<u32> {
143    match (total, delta) {
144        (Some(total), Some(delta)) => Some(total.saturating_add(delta)),
145        (None, Some(delta)) => Some(delta),
146        (Some(total), None) => Some(total),
147        (None, None) => None,
148    }
149}
150
151/// Lightweight turn step counter and tool-call log (no LLM dependency).
152///
153/// Prefer [`TurnContext`] for the live engine loop; this remains for callers
154/// that only need step/tool-call tracking without usage aggregation.
155#[derive(Debug)]
156pub struct TurnState {
157    pub id: String,
158    pub step: u32,
159    pub max_steps: u32,
160    pub tool_calls: Vec<TurnToolCall>,
161    pub cancelled: bool,
162}
163
164impl TurnState {
165    pub fn new(max_steps: u32) -> Self {
166        Self {
167            id: uuid::Uuid::new_v4().to_string(),
168            step: 0,
169            max_steps,
170            tool_calls: Vec::new(),
171            cancelled: false,
172        }
173    }
174
175    pub fn next_step(&mut self) -> bool {
176        self.step += 1;
177        self.step <= self.max_steps
178    }
179
180    pub fn at_max_steps(&self) -> bool {
181        self.step >= self.max_steps
182    }
183
184    pub fn record_tool_call(&mut self, call: TurnToolCall) {
185        self.tool_calls.push(call);
186    }
187
188    pub fn cancel(&mut self) {
189        self.cancelled = true;
190    }
191
192    /// Sorted, de-duplicated tool names called so far.
193    pub fn tool_names(&self) -> Vec<String> {
194        let mut names: Vec<String> = self.tool_calls.iter().map(|tc| tc.name.clone()).collect();
195        names.sort();
196        names.dedup();
197        names
198    }
199}