Skip to main content

nanocodex_agent/agent/
turn.rs

1use super::backend::{BackendFuture, BackendTurnKey, LifecycleBackend};
2use super::*;
3use nanocodex_oai_api::PromptValidationError;
4
5/// Completion handle for an accepted turn.
6///
7/// A turn is both a [`Future`] for its final typed result and a [`Stream`] of
8/// optional per-turn events. Result readiness is independent from consuming or
9/// closing that event stream.
10///
11/// Dropping this handle does not cancel the accepted turn. Use [`Self::cancel`]
12/// before dropping it when the work should stop.
13#[must_use = "a turn continues running when dropped; await result(), control it, or explicitly drop it"]
14pub struct Turn {
15    pub(super) control: TurnControl,
16    pub(super) request_id: Option<String>,
17    pub(super) events: AgentEvents,
18    pub(super) result: BackendFuture<Result<TurnResult>>,
19}
20
21/// Outcome of routing live user input into an agent session.
22///
23/// Live input adapters normally want to steer the current regular turn when
24/// one exists and start a new turn only when the agent is idle.
25/// [`Nanocodex::route_prompt`](crate::Nanocodex::route_prompt) performs that
26/// decision atomically in the agent driver and returns this outcome.
27pub enum PromptRoute {
28    /// The agent was idle, so the prompt started a new independently awaitable turn.
29    Started(Turn),
30    /// The prompt was admitted to the current turn's steering queue.
31    Steered,
32}
33
34impl Turn {
35    /// Returns the durable request identity selected during prompt admission.
36    ///
37    /// A caller-supplied [`PromptRequest::request_id`] is returned unchanged.
38    /// When an execution policy generated the identity, this returns the
39    /// generated or recovered journal operation ID. Agents without an attached
40    /// execution policy do not assign request identities.
41    #[must_use]
42    pub fn request_id(&self) -> Option<&str> {
43        self.request_id.as_deref()
44    }
45
46    /// Returns a cheap cloneable capability targeting this exact turn.
47    #[must_use]
48    pub fn control(&self) -> TurnControl {
49        self.control.clone()
50    }
51
52    /// Injects additional input into this turn at its next safe model boundary.
53    ///
54    /// # Errors
55    ///
56    /// Returns an error for an empty prompt, when this turn is queued or no
57    /// longer active, when its steering queue is full, or if the driver stops.
58    pub async fn steer(&self, prompt: impl Into<Prompt>) -> Result<()> {
59        self.control.steer(prompt).await
60    }
61
62    /// Admits a steer with an identity for later withdrawal.
63    ///
64    /// # Errors
65    /// See [`TurnControl::steer_with_id`].
66    pub async fn steer_with_id(&self, id: String, prompt: impl Into<Prompt>) -> Result<()> {
67        self.control.steer_with_id(id, prompt).await
68    }
69
70    /// Withdraws the latest steer before the next model boundary.
71    ///
72    /// # Errors
73    /// See [`TurnControl::withdraw_steer`].
74    pub async fn withdraw_steer(&self, id: String) -> Result<bool> {
75        self.control.withdraw_steer(id).await
76    }
77
78    /// Cancels this exact unfinished turn.
79    ///
80    /// A queued turn is removed before execution and acknowledged immediately;
81    /// its result and terminal event retain their FIFO position behind earlier
82    /// turns. An active turn waits for its model and tool resources to stop
83    /// before cancellation is acknowledged.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error when this turn has already finished or if the driver
88    /// stops.
89    pub async fn cancel(&self) -> Result<()> {
90        self.control.cancel().await
91    }
92
93    /// Waits for and returns the final typed turn result.
94    ///
95    /// This is equivalent to awaiting the turn directly. It does not wait for
96    /// the per-turn event stream to be consumed or closed. Applications that
97    /// need every event should consume the independently returned
98    /// [`AgentEvents`] stream.
99    ///
100    /// # Errors
101    ///
102    /// Returns the model-run failure or an error if the driver stopped early.
103    pub async fn result(self) -> Result<TurnResult> {
104        self.await
105    }
106}
107
108impl Stream for Turn {
109    type Item = AgentEvent;
110
111    fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {
112        Pin::new(&mut self.events).poll_next(context)
113    }
114}
115
116impl Future for Turn {
117    type Output = Result<TurnResult>;
118
119    fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
120        self.result.as_mut().poll(context)
121    }
122}
123
124/// Cheap cloneable control capability for one accepted turn.
125#[derive(Clone)]
126pub struct TurnControl {
127    pub(super) key: BackendTurnKey,
128    pub(super) backend: Arc<dyn LifecycleBackend>,
129}
130
131impl TurnControl {
132    /// Injects additional input into the targeted turn.
133    ///
134    /// # Errors
135    ///
136    /// Returns an error for an empty prompt, when the turn is not active, when
137    /// its steering queue is full, or if the driver stops.
138    pub async fn steer(&self, prompt: impl Into<Prompt>) -> Result<()> {
139        let prompt = prompt.into();
140        prompt.validate().map_err(steer_validation_error)?;
141        self.backend.steer(self.key, prompt).await
142    }
143
144    /// Admits input with a caller-owned identity unique within this turn.
145    ///
146    /// # Errors
147    /// Returns admission errors or an error if identified steering is unsupported.
148    pub async fn steer_with_id(&self, id: String, prompt: impl Into<Prompt>) -> Result<()> {
149        if id.is_empty() {
150            return Err(NanocodexError::InvalidRequest(
151                "steer identity must not be empty".into(),
152            ));
153        }
154        let prompt = prompt.into();
155        prompt.validate().map_err(steer_validation_error)?;
156        self.backend.steer_with_id(self.key, id, prompt).await
157    }
158
159    /// Withdraws the latest accepted steer before its model boundary.
160    /// Returns false if the identity is no longer latest or was already consumed.
161    ///
162    /// # Errors
163    /// Returns an error if persistence fails, the driver stops, or withdrawal is unsupported.
164    pub async fn withdraw_steer(&self, id: String) -> Result<bool> {
165        self.backend.withdraw_steer(self.key, id).await
166    }
167
168    /// Cancels the targeted unfinished turn.
169    ///
170    /// # Errors
171    ///
172    /// Returns an error when the turn has already finished or if the driver
173    /// stops.
174    pub async fn cancel(&self) -> Result<()> {
175        self.backend.cancel(self.key).await
176    }
177}
178
179fn steer_validation_error(error: PromptValidationError) -> NanocodexError {
180    let message = match error {
181        PromptValidationError::EmptyInstruction => "steer instruction must not be empty".to_owned(),
182        error => error.to_string(),
183    };
184    NanocodexError::InvalidRequest(message)
185}
186
187#[derive(Clone, Copy, Eq, PartialEq)]
188#[cfg(feature = "openai")]
189pub(super) struct TurnKey(pub(super) u64);
190
191/// Final result of a completed turn.
192#[derive(Clone)]
193#[non_exhaustive]
194pub struct TurnResult {
195    pub(super) request_id: Option<String>,
196    pub(super) final_message: String,
197    pub(super) usage: Option<TurnUsage>,
198    #[cfg(feature = "openai")]
199    pub(super) checkpoint: TurnCheckpoint,
200}
201
202#[derive(Clone)]
203#[cfg(feature = "openai")]
204pub(super) enum TurnCheckpoint {
205    Live(Arc<CommittedSession>),
206    Replayed(SessionSnapshot),
207    Unavailable,
208}
209
210impl TurnResult {
211    /// Returns the durable request identity selected during prompt admission.
212    #[must_use]
213    pub fn request_id(&self) -> Option<&str> {
214        self.request_id.as_deref()
215    }
216
217    /// Returns the final assistant message for this completed turn.
218    #[must_use]
219    pub fn final_message(&self) -> &str {
220        &self.final_message
221    }
222
223    /// Consumes the result and returns its final assistant message.
224    #[must_use]
225    pub fn into_final_message(self) -> String {
226        self.final_message
227    }
228
229    /// Returns exact aggregate token usage when reported by the backend.
230    #[must_use]
231    pub const fn usage(&self) -> Option<&TurnUsage> {
232        self.usage.as_ref()
233    }
234
235    /// Returns a serializable, caller-owned session snapshot when retained by the backend.
236    ///
237    /// The snapshot contains the complete unredacted model-visible conversation,
238    /// including reasoning payloads and tool inputs and outputs. Applications are
239    /// responsible for protecting and retaining serialized snapshots appropriately.
240    #[must_use]
241    #[allow(clippy::missing_const_for_fn)]
242    pub fn snapshot(&self) -> Option<SessionSnapshot> {
243        #[cfg(feature = "openai")]
244        match &self.checkpoint {
245            TurnCheckpoint::Live(checkpoint) => Some(checkpoint.snapshot()),
246            TurnCheckpoint::Replayed(snapshot) => Some(snapshot.clone()),
247            TurnCheckpoint::Unavailable => None,
248        }
249        #[cfg(not(feature = "openai"))]
250        None
251    }
252
253    /// Constructs a completed result for a backend without a transferable
254    /// local session checkpoint.
255    #[doc(hidden)]
256    #[must_use]
257    pub const fn from_backend(
258        request_id: Option<String>,
259        final_message: String,
260        usage: Option<TurnUsage>,
261    ) -> Self {
262        Self {
263            request_id,
264            final_message,
265            usage,
266            #[cfg(feature = "openai")]
267            checkpoint: TurnCheckpoint::Unavailable,
268        }
269    }
270}
271
272impl fmt::Debug for TurnResult {
273    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
274        formatter
275            .debug_struct("TurnResult")
276            .field("final_message", &self.final_message)
277            .finish_non_exhaustive()
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::TurnResult;
284
285    #[test]
286    fn backend_result_can_omit_usage_and_local_snapshot() {
287        let result = TurnResult::from_backend(None, "done".to_owned(), None);
288
289        assert_eq!(result.final_message(), "done");
290        assert!(result.usage().is_none());
291        assert!(result.snapshot().is_none());
292    }
293}
294
295/// One prompt submission with an optional execution identity.
296///
297/// When an execution policy is attached, the agent automatically assigns an
298/// operation ID to requests that omit one. Attach a caller-owned ID when an
299/// external job, webhook, or host retry resubmits the same logical operation.
300#[derive(Clone, Debug)]
301pub struct PromptRequest {
302    pub(super) prompt: Prompt,
303    pub(super) request_id: Option<String>,
304    pub(super) cancel_on_admission: bool,
305}
306
307impl PromptRequest {
308    /// Creates a prompt submission without a caller-owned operation identity.
309    ///
310    /// A policy-enabled agent assigns a unique operation ID before accepting
311    /// this request.
312    #[must_use]
313    pub fn new(prompt: impl Into<Prompt>) -> Self {
314        Self {
315            prompt: prompt.into(),
316            request_id: None,
317            cancel_on_admission: false,
318        }
319    }
320
321    /// Supplies a stable caller-owned request identity.
322    ///
323    /// When omitted, an execution policy generates an identity before the
324    /// prompt is accepted. Resubmitting the same request ID with the same
325    /// prompt resumes or replays that durable operation; reusing it for a
326    /// different prompt is rejected as a conflict.
327    #[must_use]
328    pub fn request_id(mut self, request_id: impl Into<String>) -> Self {
329        self.request_id = Some(request_id.into());
330        self
331    }
332
333    /// Cancels this prompt at its durable admission boundary before model or
334    /// tool work can start.
335    #[doc(hidden)]
336    #[must_use]
337    pub const fn cancel_on_admission(mut self) -> Self {
338        self.cancel_on_admission = true;
339        self
340    }
341}
342
343impl From<Prompt> for PromptRequest {
344    fn from(prompt: Prompt) -> Self {
345        Self::new(prompt)
346    }
347}
348
349impl From<String> for PromptRequest {
350    fn from(prompt: String) -> Self {
351        Self::new(prompt)
352    }
353}
354
355impl From<&str> for PromptRequest {
356    fn from(prompt: &str) -> Self {
357        Self::new(prompt)
358    }
359}
360
361/// Optional model policy for a newly spawned clean agent.
362///
363/// Omitted values inherit the invoking agent's settings at the model boundary
364/// where the spawn command is handled.
365#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
366pub struct SpawnOptions {
367    pub(super) model: Option<Model>,
368    pub(super) thinking: Option<Thinking>,
369}
370
371impl SpawnOptions {
372    /// Starts an inherited spawn configuration.
373    #[must_use]
374    pub const fn new() -> Self {
375        Self {
376            model: None,
377            thinking: None,
378        }
379    }
380
381    /// Overrides the model for the new agent without changing its parent.
382    #[must_use]
383    pub const fn model(mut self, model: Model) -> Self {
384        self.model = Some(model);
385        self
386    }
387
388    /// Overrides the reasoning effort for the new agent without changing its parent.
389    #[must_use]
390    pub const fn thinking(mut self, thinking: Thinking) -> Self {
391        self.thinking = Some(thinking);
392        self
393    }
394    /// Returns the requested model override, when supplied.
395    #[doc(hidden)]
396    #[must_use]
397    pub const fn selected_model(&self) -> Option<Model> {
398        self.model
399    }
400
401    /// Returns the requested reasoning-effort override, when supplied.
402    #[doc(hidden)]
403    #[must_use]
404    pub const fn selected_thinking(&self) -> Option<Thinking> {
405        self.thinking
406    }
407}
408
409#[cfg(feature = "openai")]
410pub(super) enum Command {
411    Prompt {
412        key: TurnKey,
413        prompt: Prompt,
414        execution_operation: Option<ExecutionOperation>,
415        accepted: Option<oneshot::Sender<Result<String>>>,
416        cancel_on_admission: bool,
417        thinking: Option<Thinking>,
418        fast_mode: Option<bool>,
419        parent: Option<tracing::Span>,
420        events: EventSink,
421        result: oneshot::Sender<Result<TurnResult>>,
422    },
423    Steer {
424        key: TurnKey,
425        prompt: Prompt,
426        result: oneshot::Sender<Result<()>>,
427    },
428    SteerWithId {
429        key: TurnKey,
430        id: String,
431        prompt: Prompt,
432        result: oneshot::Sender<Result<()>>,
433    },
434    WithdrawSteer {
435        key: TurnKey,
436        id: String,
437        result: oneshot::Sender<Result<bool>>,
438    },
439    RoutePrompt {
440        key: TurnKey,
441        prompt: Prompt,
442        parent: Option<tracing::Span>,
443        events: EventSink,
444        turn_result: oneshot::Sender<Result<TurnResult>>,
445        route_result: oneshot::Sender<Result<PromptRouteKind>>,
446    },
447    Cancel {
448        key: TurnKey,
449        result: oneshot::Sender<Result<()>>,
450    },
451    Fork {
452        checkpoint: Option<Arc<CommittedSession>>,
453        result: oneshot::Sender<Result<(Nanocodex, AgentEvents)>>,
454    },
455    Spawn {
456        options: SpawnOptions,
457        host_context: Option<Arc<str>>,
458        result: oneshot::Sender<Result<(Nanocodex, AgentEvents)>>,
459    },
460    SpawnBatch {
461        count: usize,
462        observer: Option<Arc<SpawnObserver>>,
463        host_context: Option<Arc<str>>,
464        result: oneshot::Sender<Result<Vec<(Nanocodex, AgentEvents)>>>,
465    },
466    SetModel {
467        model: Model,
468        result: oneshot::Sender<Result<()>>,
469    },
470    SetThinking {
471        thinking: Thinking,
472        result: oneshot::Sender<Result<()>>,
473    },
474    SetFastMode {
475        enabled: bool,
476        result: oneshot::Sender<Result<()>>,
477    },
478    Compact {
479        parent: Option<tracing::Span>,
480        result: oneshot::Sender<Result<()>>,
481    },
482    AppendDeveloperMessage {
483        text: String,
484        result: oneshot::Sender<Result<AgentSessionContext>>,
485    },
486    Context {
487        result: oneshot::Sender<Result<AgentSessionContext>>,
488    },
489    Shutdown,
490}
491
492#[cfg(feature = "openai")]
493#[derive(Clone)]
494pub(super) enum ExecutionOperation {
495    Caller(String),
496    Automatic(String),
497    Admitted(String),
498    Recovered(String),
499}
500
501#[cfg(feature = "openai")]
502impl ExecutionOperation {
503    pub(super) fn into_id(self) -> String {
504        match self {
505            Self::Caller(operation_id)
506            | Self::Automatic(operation_id)
507            | Self::Admitted(operation_id)
508            | Self::Recovered(operation_id) => operation_id,
509        }
510    }
511
512    pub(super) fn id(&self) -> &str {
513        match self {
514            Self::Caller(operation_id)
515            | Self::Automatic(operation_id)
516            | Self::Admitted(operation_id)
517            | Self::Recovered(operation_id) => operation_id,
518        }
519    }
520
521    pub(super) const fn is_recovered(&self) -> bool {
522        matches!(self, Self::Recovered(_))
523    }
524}
525
526#[cfg(feature = "openai")]
527pub(super) enum PromptRouteKind {
528    Started { request_id: Option<String> },
529    Steered,
530}
531
532#[cfg(feature = "openai")]
533pub(super) enum QueuedTurn {
534    Pending {
535        key: TurnKey,
536        prompt: Prompt,
537        execution_operation: Option<ExecutionOperation>,
538        thinking: Thinking,
539        fast_mode: bool,
540        parent: Option<tracing::Span>,
541        events: EventSink,
542        result: oneshot::Sender<Result<TurnResult>>,
543    },
544    Cancelled {
545        key: TurnKey,
546        prompt: Prompt,
547        execution_operation: Option<ExecutionOperation>,
548        cancellation_committed: bool,
549        thinking: Thinking,
550        fast_mode: bool,
551        parent: Option<tracing::Span>,
552        events: EventSink,
553        result: oneshot::Sender<Result<TurnResult>>,
554    },
555}