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}