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