Skip to main content

zeph_context/
turn_context.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Per-turn execution context shared across agent phases.
5
6use tokio_util::sync::CancellationToken;
7use zeph_config::security::TimeoutConfig;
8
9/// Monotonically increasing per-conversation turn identifier.
10///
11/// Moved from `zeph-core` to `zeph-context` so [`TurnContext`] can be defined here
12/// without creating a forbidden `zeph-context → zeph-core` dependency.
13///
14/// `TurnId(0)` is the first turn in a conversation. Values are strictly increasing by 1.
15/// The counter resets to 0 when a new conversation starts (e.g., via `/new`).
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub struct TurnId(pub u64);
18
19impl TurnId {
20    /// Return the next turn ID in sequence.
21    ///
22    /// Saturates at `u64::MAX` rather than wrapping or panicking.
23    #[must_use]
24    pub fn next(self) -> TurnId {
25        TurnId(self.0.saturating_add(1))
26    }
27}
28
29impl std::fmt::Display for TurnId {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        write!(f, "{}", self.0)
32    }
33}
34
35/// Per-turn execution context shared across phases (`loop`, `compose`, `persist`).
36///
37/// `TurnContext` is `Send + 'static` and cheaply cloneable so it can be passed by value
38/// into subsystems that may outlive a `&mut Turn` borrow (background tasks, sub-services
39/// extracted to other crates in Phase 2 of the agent decomposition).
40///
41/// It carries only data that is (a) immutable for the duration of the turn or (b)
42/// intrinsically `Send + Clone` (the cancellation token).
43///
44/// # Examples
45///
46/// ```
47/// use zeph_context::turn_context::{TurnContext, TurnId};
48/// use zeph_config::security::TimeoutConfig;
49/// use tokio_util::sync::CancellationToken;
50///
51/// let ctx = TurnContext::new(TurnId(0), CancellationToken::new(), TimeoutConfig::default());
52/// assert_eq!(ctx.id, TurnId(0));
53/// ```
54#[derive(Debug, Clone)]
55pub struct TurnContext {
56    /// Monotonically increasing identifier for this turn within the conversation.
57    pub id: TurnId,
58    /// Per-turn cancellation token. A fresh token is created in `Agent::begin_turn`.
59    /// Cancelled when the user aborts the turn or the agent shuts down.
60    pub cancel_token: CancellationToken,
61    /// Effective timeout configuration snapshotted at the start of the turn.
62    ///
63    /// Snapshotting (rather than reading from a shared config) ensures the turn's
64    /// timeout policy is stable even if the live config is reloaded mid-turn.
65    pub timeouts: TimeoutConfig,
66    /// Optional channel-scoped tool allowlist for this turn.
67    ///
68    /// `None` means no channel-level restriction applies (other layers may still gate tool
69    /// access). Always `None` until Phase 2 wires channel config into the agent runtime.
70    ///
71    /// TODO(#3498): populate from active channel config during Phase 2 crate extraction.
72    pub tool_allowlist: Option<Vec<String>>,
73}
74
75impl TurnContext {
76    /// Create a new `TurnContext`.
77    ///
78    /// # Examples
79    ///
80    /// ```
81    /// use zeph_context::turn_context::{TurnContext, TurnId};
82    /// use zeph_config::security::TimeoutConfig;
83    /// use tokio_util::sync::CancellationToken;
84    ///
85    /// let ctx = TurnContext::new(TurnId(1), CancellationToken::new(), TimeoutConfig::default());
86    /// assert_eq!(ctx.id, TurnId(1));
87    /// ```
88    #[must_use]
89    pub fn new(id: TurnId, cancel_token: CancellationToken, timeouts: TimeoutConfig) -> Self {
90        Self {
91            id,
92            cancel_token,
93            timeouts,
94            tool_allowlist: None,
95        }
96    }
97}
98
99const _: () = {
100    fn assert_send_static<T: Send + 'static>() {}
101    fn check() {
102        assert_send_static::<TurnContext>();
103        assert_send_static::<TurnId>();
104    }
105    let _ = check;
106};
107
108#[cfg(test)]
109mod tests {
110    use tokio_util::sync::CancellationToken;
111    use zeph_config::security::TimeoutConfig;
112
113    use super::*;
114
115    #[test]
116    fn turn_id_next_increments() {
117        assert_eq!(TurnId(3).next(), TurnId(4));
118    }
119
120    #[test]
121    fn turn_id_next_saturates_at_max() {
122        assert_eq!(TurnId(u64::MAX).next(), TurnId(u64::MAX));
123    }
124
125    #[test]
126    fn turn_id_display() {
127        assert_eq!(TurnId(42).to_string(), "42");
128    }
129
130    #[test]
131    fn turn_context_new_fields() {
132        let token = CancellationToken::new();
133        let ctx = TurnContext::new(TurnId(1), token.clone(), TimeoutConfig::default());
134        assert_eq!(ctx.id, TurnId(1));
135        assert!(ctx.tool_allowlist.is_none());
136    }
137
138    #[test]
139    fn turn_context_clone_shares_cancel_token() {
140        let ctx = TurnContext::new(
141            TurnId(0),
142            CancellationToken::new(),
143            TimeoutConfig::default(),
144        );
145        let cloned = ctx.clone();
146        ctx.cancel_token.cancel();
147        assert!(cloned.cancel_token.is_cancelled());
148    }
149}