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). When `Some`, only tools whose names appear in the list may be dispatched;
70    /// any call to a tool not in the list is rejected before execution.
71    ///
72    /// Populated from the active channel's `allowed_tools` config by the agent runtime
73    /// at turn start via [`TurnContext::with_tool_allowlist`].
74    pub tool_allowlist: Option<Vec<String>>,
75}
76
77impl TurnContext {
78    /// Create a new `TurnContext` with no channel-level tool restriction.
79    ///
80    /// Use [`with_tool_allowlist`](Self::with_tool_allowlist) to set a channel-scoped allowlist.
81    ///
82    /// # Examples
83    ///
84    /// ```
85    /// use zeph_context::turn_context::{TurnContext, TurnId};
86    /// use zeph_config::security::TimeoutConfig;
87    /// use tokio_util::sync::CancellationToken;
88    ///
89    /// let ctx = TurnContext::new(TurnId(1), CancellationToken::new(), TimeoutConfig::default());
90    /// assert_eq!(ctx.id, TurnId(1));
91    /// assert!(ctx.tool_allowlist.is_none());
92    /// ```
93    #[must_use]
94    pub fn new(id: TurnId, cancel_token: CancellationToken, timeouts: TimeoutConfig) -> Self {
95        Self {
96            id,
97            cancel_token,
98            timeouts,
99            tool_allowlist: None,
100        }
101    }
102
103    /// Set the channel-scoped tool allowlist for this turn.
104    ///
105    /// `None` clears any existing restriction. `Some(vec![])` denies all tools.
106    ///
107    /// # Examples
108    ///
109    /// ```
110    /// use zeph_context::turn_context::{TurnContext, TurnId};
111    /// use zeph_config::security::TimeoutConfig;
112    /// use tokio_util::sync::CancellationToken;
113    ///
114    /// let ctx = TurnContext::new(TurnId(0), CancellationToken::new(), TimeoutConfig::default())
115    ///     .with_tool_allowlist(Some(vec!["shell".to_owned(), "grep".to_owned()]));
116    /// assert!(ctx.is_tool_allowed("shell"));
117    /// assert!(!ctx.is_tool_allowed("web_scrape"));
118    /// ```
119    #[must_use]
120    pub fn with_tool_allowlist(mut self, allowlist: Option<Vec<String>>) -> Self {
121        self.tool_allowlist = allowlist;
122        self
123    }
124
125    /// Returns `true` if `tool_name` is permitted by the channel-level allowlist.
126    ///
127    /// When no allowlist is set (`None`), all tools are permitted.
128    /// When the allowlist is `Some`, only tools explicitly listed are permitted.
129    ///
130    /// Comparison is **case-sensitive**: `"Shell"` and `"shell"` are treated as different
131    /// names. Callers must normalize tool names to lowercase before populating the allowlist
132    /// if case-insensitive matching is required.
133    ///
134    /// # Examples
135    ///
136    /// ```
137    /// use zeph_context::turn_context::{TurnContext, TurnId};
138    /// use zeph_config::security::TimeoutConfig;
139    /// use tokio_util::sync::CancellationToken;
140    ///
141    /// let unrestricted = TurnContext::new(TurnId(0), CancellationToken::new(), TimeoutConfig::default());
142    /// assert!(unrestricted.is_tool_allowed("anything"));
143    ///
144    /// let restricted = unrestricted.with_tool_allowlist(Some(vec!["shell".to_owned()]));
145    /// assert!(restricted.is_tool_allowed("shell"));
146    /// assert!(!restricted.is_tool_allowed("web_scrape"));
147    /// ```
148    #[must_use]
149    pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
150        match &self.tool_allowlist {
151            None => true,
152            Some(list) => list.iter().any(|t| t == tool_name),
153        }
154    }
155}
156
157const _: () = {
158    fn assert_send_static<T: Send + 'static>() {}
159    fn check() {
160        assert_send_static::<TurnContext>();
161        assert_send_static::<TurnId>();
162    }
163    let _ = check;
164};
165
166#[cfg(test)]
167mod tests {
168    use tokio_util::sync::CancellationToken;
169    use zeph_config::security::TimeoutConfig;
170
171    use super::*;
172
173    #[test]
174    fn turn_id_next_increments() {
175        assert_eq!(TurnId(3).next(), TurnId(4));
176    }
177
178    #[test]
179    fn turn_id_next_saturates_at_max() {
180        assert_eq!(TurnId(u64::MAX).next(), TurnId(u64::MAX));
181    }
182
183    #[test]
184    fn turn_id_display() {
185        assert_eq!(TurnId(42).to_string(), "42");
186    }
187
188    #[test]
189    fn turn_context_new_fields() {
190        let token = CancellationToken::new();
191        let ctx = TurnContext::new(TurnId(1), token.clone(), TimeoutConfig::default());
192        assert_eq!(ctx.id, TurnId(1));
193        assert!(ctx.tool_allowlist.is_none());
194    }
195
196    #[test]
197    fn turn_context_tool_allowlist_none_permits_all() {
198        let ctx = TurnContext::new(
199            TurnId(0),
200            CancellationToken::new(),
201            TimeoutConfig::default(),
202        );
203        assert!(ctx.is_tool_allowed("shell"));
204        assert!(ctx.is_tool_allowed("anything"));
205    }
206
207    #[test]
208    fn turn_context_tool_allowlist_some_filters() {
209        let ctx = TurnContext::new(
210            TurnId(0),
211            CancellationToken::new(),
212            TimeoutConfig::default(),
213        )
214        .with_tool_allowlist(Some(vec!["shell".to_owned(), "grep".to_owned()]));
215        assert!(ctx.is_tool_allowed("shell"));
216        assert!(ctx.is_tool_allowed("grep"));
217        assert!(!ctx.is_tool_allowed("web_scrape"));
218    }
219
220    #[test]
221    fn turn_context_tool_allowlist_empty_denies_all() {
222        let ctx = TurnContext::new(
223            TurnId(0),
224            CancellationToken::new(),
225            TimeoutConfig::default(),
226        )
227        .with_tool_allowlist(Some(vec![]));
228        assert!(!ctx.is_tool_allowed("shell"));
229    }
230
231    #[test]
232    fn turn_context_with_tool_allowlist_none_clears() {
233        let ctx = TurnContext::new(
234            TurnId(0),
235            CancellationToken::new(),
236            TimeoutConfig::default(),
237        )
238        .with_tool_allowlist(Some(vec!["shell".to_owned()]))
239        .with_tool_allowlist(None);
240        assert!(ctx.is_tool_allowed("anything"));
241    }
242
243    #[test]
244    fn turn_context_clone_shares_cancel_token() {
245        let ctx = TurnContext::new(
246            TurnId(0),
247            CancellationToken::new(),
248            TimeoutConfig::default(),
249        );
250        let cloned = ctx.clone();
251        ctx.cancel_token.cancel();
252        assert!(cloned.cancel_token.is_cancelled());
253    }
254}