Skip to main content

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}