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}