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