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}