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