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}