Skip to main content

zeph_core/agent/
turn.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Per-turn state carrier for the agent loop.
5//!
6//! A [`Turn`] is created at the start of each `process_user_message` call, lives on the call
7//! stack for the duration of the turn, and is consumed at the end via `Agent::end_turn`.
8//! It is never stored on the `Agent` struct.
9//!
10//! # Phase 1 scope
11//!
12//! Only input, ID, metrics (timings only), and the cancellation token are tracked.
13//! Fields `context`, `response_text`, and `tool_results` are deferred to Phase 2.
14
15use tokio_util::sync::CancellationToken;
16use zeph_llm::provider::MessagePart;
17
18use crate::metrics::TurnTimings;
19
20/// Monotonically increasing per-conversation turn identifier.
21///
22/// Wraps `debug_state.iteration_counter` as a proper newtype, enabling turn IDs to be passed
23/// through the turn lifecycle and recorded in metrics and traces.
24///
25/// `TurnId(0)` is the first turn in a conversation. Values are strictly increasing by 1.
26/// The counter resets to 0 when a new conversation starts (e.g., via `/new`).
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub struct TurnId(pub u64);
29
30impl TurnId {
31    /// Return the next turn ID in sequence.
32    #[allow(dead_code)]
33    pub(crate) fn next(self) -> TurnId {
34        TurnId(self.0 + 1)
35    }
36}
37
38impl std::fmt::Display for TurnId {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        write!(f, "{}", self.0)
41    }
42}
43
44/// Resolved input for a single agent turn.
45///
46/// Built from a `ChannelMessage` after attachment resolution and image extraction.
47/// Separates input parsing from input processing and bundles the two values previously
48/// passed as separate arguments to `process_user_message`.
49pub struct TurnInput {
50    /// Plain-text user input. May be empty for image-only messages.
51    pub text: String,
52    /// Decoded image parts extracted from attachments or inline data.
53    pub image_parts: Vec<MessagePart>,
54}
55
56impl TurnInput {
57    /// Create input from text and image parts.
58    #[must_use]
59    pub fn new(text: String, image_parts: Vec<MessagePart>) -> Self {
60        Self { text, image_parts }
61    }
62}
63
64/// Per-turn performance measurements captured during the turn lifecycle.
65///
66/// In Phase 1, only `timings` is populated. Token counts and cost are managed via the existing
67/// `MetricsState` path and are deferred to Phase 2.
68#[derive(Debug, Default, Clone)]
69pub struct TurnMetrics {
70    /// Turn timing breakdown populated at each lifecycle phase boundary.
71    pub timings: TurnTimings,
72}
73
74/// Record of a single tool execution within a turn.
75///
76/// Defined in Phase 1 for forward compatibility. Populated in Phase 2 when
77/// `Turn.tool_results` is wired through the tool execution chain.
78#[allow(dead_code)]
79pub(crate) struct ToolOutputRecord {
80    /// Registered tool name (e.g., `"shell"`, `"web_scrape"`).
81    pub(crate) tool_name: String,
82    /// Short human-readable summary of the tool output.
83    pub(crate) summary: String,
84    /// Wall-clock execution time in milliseconds.
85    pub(crate) latency_ms: u64,
86}
87
88/// Complete state of a single agent turn from input through metrics capture.
89///
90/// Created at the start of `process_user_message`, populated through the turn lifecycle,
91/// and consumed at the end for metrics emission and trace completion.
92///
93/// **Ownership**: `Turn` is stack-owned — created in `begin_turn`, passed through sub-methods
94/// by `&mut Turn`, and consumed in `end_turn`. It is never stored on the `Agent` struct.
95///
96/// **Phase 1 scope**: carries `id`, `input`, `metrics`, and `cancel_token` only.
97pub struct Turn {
98    /// Monotonically increasing identifier for this turn within the conversation.
99    pub id: TurnId,
100    /// Resolved user input for this turn.
101    pub input: TurnInput,
102    /// Per-turn metrics accumulated during the turn lifecycle.
103    pub metrics: TurnMetrics,
104    /// Per-turn cancellation token. Cancelled when the user aborts the turn or the agent shuts
105    /// down. Created fresh in [`Turn::new`] so each turn has an independent token.
106    pub cancel_token: CancellationToken,
107}
108
109impl Turn {
110    /// Create a new turn with the given ID and input.
111    ///
112    /// A fresh [`CancellationToken`] is created for each turn so that cancelling one turn
113    /// does not affect subsequent turns.
114    ///
115    /// # Examples
116    ///
117    /// ```no_run
118    /// # use zeph_core::agent::turn::{Turn, TurnId, TurnInput};
119    /// # use zeph_llm::provider::MessagePart;
120    /// let input = TurnInput::new("hello".to_owned(), vec![]);
121    /// let turn = Turn::new(TurnId(0), input);
122    /// assert_eq!(turn.id, TurnId(0));
123    /// ```
124    #[must_use]
125    pub fn new(id: TurnId, input: TurnInput) -> Self {
126        Self {
127            id,
128            input,
129            metrics: TurnMetrics::default(),
130            cancel_token: CancellationToken::new(),
131        }
132    }
133
134    /// Return the turn ID.
135    #[must_use]
136    pub fn id(&self) -> TurnId {
137        self.id
138    }
139
140    /// Return an immutable reference to the turn metrics.
141    #[must_use]
142    pub fn metrics_snapshot(&self) -> &TurnMetrics {
143        &self.metrics
144    }
145
146    /// Return a mutable reference to the turn metrics.
147    pub fn metrics_mut(&mut self) -> &mut TurnMetrics {
148        &mut self.metrics
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn turn_new_sets_id() {
158        let input = TurnInput::new("hello".to_owned(), vec![]);
159        let turn = Turn::new(TurnId(7), input);
160        assert_eq!(turn.id, TurnId(7));
161        assert_eq!(turn.id(), TurnId(7));
162    }
163
164    #[test]
165    fn turn_id_display() {
166        assert_eq!(TurnId(42).to_string(), "42");
167    }
168
169    #[test]
170    fn turn_id_next() {
171        assert_eq!(TurnId(3).next(), TurnId(4));
172    }
173
174    #[test]
175    fn turn_input_fields() {
176        let input = TurnInput::new("hi".to_owned(), vec![]);
177        assert_eq!(input.text, "hi");
178        assert!(input.image_parts.is_empty());
179    }
180
181    #[test]
182    fn turn_metrics_default_timings_are_zero() {
183        let m = TurnMetrics::default();
184        assert_eq!(m.timings.prepare_context_ms, 0);
185        assert_eq!(m.timings.llm_chat_ms, 0);
186    }
187
188    #[test]
189    fn turn_cancel_token_not_cancelled_on_new() {
190        let input = TurnInput::new("x".to_owned(), vec![]);
191        let turn = Turn::new(TurnId(0), input);
192        assert!(!turn.cancel_token.is_cancelled());
193    }
194
195    #[test]
196    fn turn_cancel_token_cancel() {
197        let input = TurnInput::new("x".to_owned(), vec![]);
198        let turn = Turn::new(TurnId(0), input);
199        turn.cancel_token.cancel();
200        assert!(turn.cancel_token.is_cancelled());
201    }
202
203    #[test]
204    fn turn_metrics_mut_allows_write() {
205        let input = TurnInput::new("x".to_owned(), vec![]);
206        let mut turn = Turn::new(TurnId(0), input);
207        turn.metrics_mut().timings.prepare_context_ms = 99;
208        assert_eq!(turn.metrics_snapshot().timings.prepare_context_ms, 99);
209    }
210}