1use super::backend::{BackendFuture, BackendTurnKey, LifecycleBackend};
2use super::*;
3use nanocodex_oai_api::PromptValidationError;
4
5#[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
21pub enum PromptRoute {
28 Started(Turn),
30 Steered,
32}
33
34impl Turn {
35 #[must_use]
42 pub fn request_id(&self) -> Option<&str> {
43 self.request_id.as_deref()
44 }
45
46 #[must_use]
48 pub fn control(&self) -> TurnControl {
49 self.control.clone()
50 }
51
52 pub async fn steer(&self, prompt: impl Into<Prompt>) -> Result<()> {
59 self.control.steer(prompt).await
60 }
61
62 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 pub async fn withdraw_steer(&self, id: String) -> Result<bool> {
75 self.control.withdraw_steer(id).await
76 }
77
78 pub async fn cancel(&self) -> Result<()> {
90 self.control.cancel().await
91 }
92
93 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#[derive(Clone)]
126pub struct TurnControl {
127 pub(super) key: BackendTurnKey,
128 pub(super) backend: Arc<dyn LifecycleBackend>,
129}
130
131impl TurnControl {
132 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 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 pub async fn withdraw_steer(&self, id: String) -> Result<bool> {
165 self.backend.withdraw_steer(self.key, id).await
166 }
167
168 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#[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 #[must_use]
213 pub fn request_id(&self) -> Option<&str> {
214 self.request_id.as_deref()
215 }
216
217 #[must_use]
219 pub fn final_message(&self) -> &str {
220 &self.final_message
221 }
222
223 #[must_use]
225 pub fn into_final_message(self) -> String {
226 self.final_message
227 }
228
229 #[must_use]
231 pub const fn usage(&self) -> Option<&TurnUsage> {
232 self.usage.as_ref()
233 }
234
235 #[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 #[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#[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 #[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 #[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 #[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#[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 #[must_use]
374 pub const fn new() -> Self {
375 Self {
376 model: None,
377 thinking: None,
378 }
379 }
380
381 #[must_use]
383 pub const fn model(mut self, model: Model) -> Self {
384 self.model = Some(model);
385 self
386 }
387
388 #[must_use]
390 pub const fn thinking(mut self, thinking: Thinking) -> Self {
391 self.thinking = Some(thinking);
392 self
393 }
394 #[doc(hidden)]
396 #[must_use]
397 pub const fn selected_model(&self) -> Option<Model> {
398 self.model
399 }
400
401 #[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}