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