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}