ag_agent/channel/contract.rs
1//! Shared channel trait and provider turn request/result contracts.
2
3use std::future::Future;
4use std::path::PathBuf;
5use std::pin::Pin;
6use std::sync::{Arc, Mutex};
7
8use tokio::sync::mpsc;
9
10use crate::agent::AgentResponse;
11use crate::model::agent::ReasoningLevel;
12use crate::model::turn_prompt::TurnPrompt;
13
14/// Boxed async result used by [`AgentChannel`] trait methods.
15pub type AgentFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
16
17/// Turn initiation mode for [`TurnRequest`].
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum AgentRequestKind {
20 /// Starts a fresh interactive session turn with no prior context.
21 SessionStart,
22 /// Resumes an interactive session turn, optionally replaying transcript
23 /// output into the next prompt.
24 SessionResume {
25 /// Prior session output used for history replay when present.
26 session_output: Option<String>,
27 },
28 /// Runs one utility prompt with utility protocol requirements.
29 ///
30 /// Callers may route this through an isolated one-shot channel or through
31 /// an existing session channel when the utility work needs provider
32 /// conversation continuity without normal post-turn auto-commit handling.
33 UtilityPrompt,
34 /// Reads provider account metadata without creating an agent turn.
35 AccountRead,
36}
37
38impl AgentRequestKind {
39 /// Returns the protocol request profile derived from this request kind.
40 #[must_use]
41 pub fn protocol_profile(&self) -> crate::agent::ProtocolRequestProfile {
42 match self {
43 Self::SessionStart | Self::SessionResume { .. } => {
44 crate::agent::ProtocolRequestProfile::SessionTurn
45 }
46 Self::UtilityPrompt | Self::AccountRead => {
47 crate::agent::ProtocolRequestProfile::UtilityPrompt
48 }
49 }
50 }
51
52 /// Returns whether this request resumes a prior interactive session turn.
53 #[must_use]
54 pub fn is_resume(&self) -> bool {
55 matches!(self, Self::SessionResume { .. })
56 }
57
58 /// Returns transcript output used for history replay, when present.
59 #[must_use]
60 pub fn session_output(&self) -> Option<&str> {
61 match self {
62 Self::SessionStart | Self::UtilityPrompt | Self::AccountRead => None,
63 Self::SessionResume { session_output } => session_output.as_deref(),
64 }
65 }
66}
67
68/// Input payload for one provider-agnostic agent turn.
69#[derive(Debug, Clone)]
70pub struct TurnRequest {
71 /// Session worktree folder where the agent runs.
72 pub folder: PathBuf,
73 /// Live session output buffer for app-server context reconstruction.
74 ///
75 /// App-server clients may read this buffer during a turn to access content
76 /// that was streamed before a prior crash, providing a more complete
77 /// transcript than the snapshot captured at enqueue time. CLI channels
78 /// ignore this field.
79 pub live_session_output: Option<Arc<Mutex<String>>>,
80 /// Provider-specific model identifier.
81 pub model: String,
82 /// Canonical request kind that drives transport behavior and protocol
83 /// semantics for this turn.
84 pub request_kind: AgentRequestKind,
85 /// Structured prompt payload for the turn.
86 pub prompt: TurnPrompt,
87 /// Provider-native conversation identifier loaded from persistence.
88 ///
89 /// When present, app-server channels forward this to the provider runtime
90 /// so it can attempt native context resume. CLI channels ignore this field.
91 pub provider_conversation_id: Option<String>,
92 /// Persisted provider-native conversation id that already received the
93 /// full instruction bootstrap.
94 ///
95 /// App-server channels use this to choose between a full bootstrap and a
96 /// compact reminder for the active provider context. CLI channels ignore
97 /// this field.
98 pub persisted_instruction_conversation_id: Option<String>,
99 /// Reasoning effort preference for the turn.
100 ///
101 /// Ignored by providers/models that do not support reasoning effort.
102 pub reasoning_level: ReasoningLevel,
103}
104
105/// Incremental event emitted during one agent turn.
106///
107/// Events are sent through an [`mpsc::UnboundedSender`] as the turn
108/// progresses, enabling transient loader updates without appending partial turn
109/// output into the persisted transcript.
110#[derive(Clone, Debug, PartialEq)]
111pub enum TurnEvent {
112 /// A streamed thinking/planning or tool-status fragment shown in the
113 /// transient loader.
114 ThoughtDelta(String),
115 /// The turn completed successfully with final token counts.
116 Completed {
117 /// Whether the provider reset its context for this turn.
118 context_reset: bool,
119 /// Input token count for the turn.
120 input_tokens: u64,
121 /// Output token count for the turn.
122 output_tokens: u64,
123 },
124 /// The turn failed with an error description.
125 Failed(String),
126 /// A child process PID update.
127 ///
128 /// Sent by CLI channels immediately after spawning the child process
129 /// (`Some(pid)`) and again after the child exits (`None`). Consumers
130 /// update the shared PID slot used by cancellation signals.
131 PidUpdate(Option<u32>),
132}
133
134/// Normalized result returned when one agent turn completes successfully.
135#[derive(Debug)]
136pub struct TurnResult {
137 /// Parsed agent response containing structured protocol messages.
138 pub assistant_message: AgentResponse,
139 /// Whether the provider reset its context to complete this turn.
140 pub context_reset: bool,
141 /// Input token count for the turn.
142 pub input_tokens: u64,
143 /// Output token count for the turn.
144 pub output_tokens: u64,
145 /// Provider-native conversation identifier observed after the turn.
146 ///
147 /// App-server providers return this so the worker can persist it for
148 /// future runtime restarts. CLI channels always return `None`.
149 pub provider_conversation_id: Option<String>,
150}
151
152/// Opaque reference to an active agent session.
153pub struct SessionRef {
154 /// Stable session identifier.
155 pub session_id: String,
156}
157
158/// Input payload for initiating a new agent session.
159pub struct StartSessionRequest {
160 /// Session worktree folder.
161 pub folder: PathBuf,
162 /// Stable session identifier.
163 pub session_id: String,
164}
165
166/// Typed error returned by [`AgentChannel`] operations.
167///
168/// Discriminates failure causes so the app layer can route errors without
169/// parsing formatted messages.
170#[derive(Debug, thiserror::Error)]
171pub enum AgentError {
172 /// An app-server infrastructure failure propagated from a persistent
173 /// provider runtime.
174 #[error(transparent)]
175 AppServer(#[from] crate::app_server::AppServerError),
176
177 /// A CLI backend command or process execution failure.
178 #[error("{0}")]
179 Backend(String),
180
181 /// The user explicitly interrupted the active turn.
182 #[error("{0}")]
183 InterruptedByUser(String),
184
185 /// A subprocess IO error such as a spawn failure or unavailable pipe.
186 #[error("{0}")]
187 Io(String),
188}
189
190/// Provider-agnostic session channel for executing agent turns.
191///
192/// Implementations bridge a specific transport - CLI subprocess or app-server
193/// RPC - to the unified [`TurnEvent`] stream consumed by session workers. The
194/// trait is object-safe so it can be held as `Arc<dyn AgentChannel>`.
195#[cfg_attr(any(test, feature = "test-utils"), mockall::automock)]
196pub trait AgentChannel: Send + Sync {
197 /// Initialises a provider session for the given session identifier.
198 ///
199 /// Implementations that do not maintain persistent sessions return
200 /// immediately with a [`SessionRef`] wrapping the supplied identifier.
201 fn start_session(
202 &self,
203 req: StartSessionRequest,
204 ) -> AgentFuture<Result<SessionRef, AgentError>>;
205
206 /// Executes one prompt turn and streams incremental events to `events`.
207 ///
208 /// Implementations may emit [`TurnEvent::ThoughtDelta`] values for
209 /// transient loader updates. Final transcript output is derived from the
210 /// returned [`TurnResult`] after the turn finishes.
211 ///
212 /// # Errors
213 /// Returns [`AgentError`] when the turn cannot be executed (spawn failure,
214 /// transport error) or is interrupted by a signal.
215 fn run_turn(
216 &self,
217 session_id: String,
218 req: TurnRequest,
219 events: mpsc::UnboundedSender<TurnEvent>,
220 ) -> AgentFuture<Result<TurnResult, AgentError>>;
221
222 /// Tears down the provider session associated with `session_id`.
223 ///
224 /// Implementations that do not maintain persistent sessions treat this as
225 /// a no-op and always return `Ok(())`.
226 fn shutdown_session(&self, session_id: String) -> AgentFuture<Result<(), AgentError>>;
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 /// Ensures session request kinds derive the session-turn protocol
235 /// profile.
236 fn test_agent_request_kind_session_variants_use_session_protocol_profile() {
237 // Arrange
238 let start = AgentRequestKind::SessionStart;
239 let resume = AgentRequestKind::SessionResume {
240 session_output: Some("prior output".to_string()),
241 };
242
243 // Act
244 let start_profile = start.protocol_profile();
245 let resume_profile = resume.protocol_profile();
246
247 // Assert
248 assert_eq!(
249 start_profile,
250 crate::agent::ProtocolRequestProfile::SessionTurn
251 );
252 assert_eq!(
253 resume_profile,
254 crate::agent::ProtocolRequestProfile::SessionTurn
255 );
256 }
257
258 #[test]
259 /// Ensures utility prompts derive the utility protocol profile and never
260 /// expose replay output.
261 fn test_agent_request_kind_utility_prompt_uses_utility_protocol_profile() {
262 // Arrange
263 let request_kind = AgentRequestKind::UtilityPrompt;
264
265 // Act
266 let protocol_profile = request_kind.protocol_profile();
267 let session_output = request_kind.session_output();
268
269 // Assert
270 assert_eq!(
271 protocol_profile,
272 crate::agent::ProtocolRequestProfile::UtilityPrompt
273 );
274 assert_eq!(session_output, None);
275 }
276
277 #[test]
278 /// Ensures account-read requests are non-session utility requests without
279 /// replay output.
280 fn test_agent_request_kind_account_read_uses_utility_protocol_profile() {
281 // Arrange
282 let request_kind = AgentRequestKind::AccountRead;
283
284 // Act
285 let protocol_profile = request_kind.protocol_profile();
286 let session_output = request_kind.session_output();
287
288 // Assert
289 assert_eq!(
290 protocol_profile,
291 crate::agent::ProtocolRequestProfile::UtilityPrompt
292 );
293 assert_eq!(session_output, None);
294 }
295}