Skip to main content

nanocodex_agent/agent/
handle.rs

1use super::*;
2
3#[cfg(not(target_family = "wasm"))]
4use crate::rollout::RolloutInfo;
5
6/// Cheap, cloneable command handle for an owned agent driver.
7pub struct Nanocodex {
8    pub(super) commands: mpsc::Sender<Command>,
9    pub(super) events: EventSink,
10    pub(super) next_turn: Arc<AtomicU64>,
11    pub(super) lineage_id: Arc<str>,
12    pub(super) session_id: SessionId,
13    pub(super) durability: Durability,
14    pub(super) shutdown: DriverShutdown,
15}
16
17impl Clone for Nanocodex {
18    fn clone(&self) -> Self {
19        Self {
20            commands: self.commands.clone(),
21            events: self.events.clone(),
22            next_turn: Arc::clone(&self.next_turn),
23            lineage_id: Arc::clone(&self.lineage_id),
24            session_id: self.session_id,
25            durability: self.durability.clone(),
26            shutdown: self.shutdown.clone(),
27        }
28    }
29}
30
31/// Weak child-agent capability for the driver that owns one tool runtime.
32///
33/// A tools factory receives a fresh handle for every agent driver. Holding the
34/// handle does not keep its agent alive.
35#[derive(Clone)]
36pub struct AgentHandle {
37    pub(super) commands: mpsc::WeakSender<Command>,
38}
39
40impl AgentHandle {
41    /// Starts a clean agent with the containing driver's private configuration,
42    /// service factory, workspace policy, and per-agent tools factory.
43    ///
44    /// The child receives a new session, cache lineage, conversation, driver,
45    /// WebSocket, and tool runtime. It does not inherit conversation history.
46    ///
47    /// # Errors
48    ///
49    /// Returns an error after the containing driver has stopped.
50    pub async fn spawn(&self) -> Result<(Nanocodex, AgentEvents)> {
51        let commands = self.commands()?;
52        request_spawn(&commands).await
53    }
54
55    /// Forks the containing agent's latest safe model boundary.
56    ///
57    /// # Errors
58    ///
59    /// Returns an error before the first prompt reaches a safe boundary, or
60    /// after the containing agent driver has stopped.
61    pub async fn fork(&self) -> Result<(Nanocodex, AgentEvents)> {
62        let commands = self.commands()?;
63        request_fork(&commands, None).await
64    }
65
66    fn commands(&self) -> Result<mpsc::Sender<Command>> {
67        self.commands.upgrade().ok_or(NanocodexError::AgentStopped)
68    }
69}
70
71impl Nanocodex {
72    /// Starts configuring an agent from a reusable [`OpenAi`] client recipe.
73    #[must_use]
74    pub fn builder<F>(openai: OpenAi<F>) -> NanocodexBuilder<F>
75    where
76        F: ResponsesServiceFactory,
77    {
78        let (config, factory) = into_openai_parts(openai);
79        NanocodexBuilder {
80            config,
81            tools: ToolsConfiguration::Shared(Tools::default()),
82            workspace: None,
83            session_id: None,
84            prompt_cache: PromptCacheConfig::default(),
85            codex: CodexCompatibility::default(),
86            resume: None,
87            factory,
88        }
89    }
90
91    /// Returns the stable identity used by events, transport metadata, and any rollout.
92    #[must_use]
93    pub const fn session_id(&self) -> SessionId {
94        self.session_id
95    }
96
97    /// Returns the Codex-compatible rollout identity and path when recording is enabled.
98    #[cfg(not(target_family = "wasm"))]
99    #[cfg_attr(docsrs, doc(cfg(not(target_family = "wasm"))))]
100    #[must_use]
101    pub const fn rollout(&self) -> Option<&RolloutInfo> {
102        self.durability.info()
103    }
104
105    /// Retries any pending rollout write and waits for a durable file flush.
106    ///
107    /// This is a no-op when rollout recording is disabled. CLI consumers call
108    /// it at completed turn boundaries so persistence failures are user-visible.
109    /// Flushing does not stop the live writer; call [`Self::shutdown`] at an
110    /// explicit application or session boundary.
111    ///
112    /// # Errors
113    ///
114    /// Returns an error when the configured rollout cannot be written.
115    #[cfg(not(target_family = "wasm"))]
116    #[cfg_attr(docsrs, doc(cfg(not(target_family = "wasm"))))]
117    pub async fn flush_rollout(&self) -> Result<()> {
118        self.durability.flush().await
119    }
120
121    /// Gracefully stops this agent and waits for all owned resources to close.
122    ///
123    /// Shutdown globally invalidates this handle and every clone. It cancels an
124    /// active turn, terminalizes all other accepted turns in FIFO order, waits
125    /// for model and tool cleanup, and flushes and closes the rollout writer. A
126    /// returned `Ok(())` therefore establishes a durable boundary suitable for
127    /// an immediate same-process rollout resume.
128    ///
129    /// Dropping the final handle retains the existing implicit cancellation
130    /// behavior, but offers no future that can join resource cleanup. Use this
131    /// method at an explicit application or session boundary.
132    ///
133    /// # Errors
134    ///
135    /// Returns the shared cleanup result. The first caller initiates shutdown;
136    /// concurrent and later callers on any clone await or reuse that same
137    /// result.
138    pub async fn shutdown(&self) -> Result<()> {
139        let (initiate, receiver) = self.shutdown.request();
140        if initiate && self.commands.send(Command::Shutdown).await.is_err() {
141            let outcome = match self.durability.shutdown().await {
142                Ok(()) => Err(NanocodexError::AgentStopped),
143                Err(error) => Err(error),
144            };
145            self.shutdown.complete(outcome);
146        }
147        match receiver.await {
148            Ok(Ok(())) => Ok(()),
149            Ok(Err(error)) => Err(NanocodexError::Shutdown(error)),
150            Err(_) => Err(NanocodexError::AgentStopped),
151        }
152    }
153
154    /// Accepts the agent's prompt and immediately returns its turn handle.
155    ///
156    /// # Errors
157    ///
158    /// Returns an error for an empty prompt or if the driver stopped.
159    pub async fn prompt(&self, prompt: impl Into<Prompt>) -> Result<Turn> {
160        let prompt = prompt.into();
161        if prompt.instruction.is_empty() {
162            return Err(NanocodexError::InvalidRequest(
163                "prompt instruction must not be empty".to_owned(),
164            ));
165        }
166        let key = TurnKey(self.next_turn.fetch_add(1, Ordering::Relaxed));
167        let parent = tracing::Span::current();
168        let parent = (!parent.is_disabled()).then_some(parent);
169        let (events, event_stream) = self.events.mirrored_channel();
170        let (result, receiver) = oneshot::channel();
171        if self
172            .commands
173            .send(Command::Prompt {
174                key,
175                prompt,
176                thinking: None,
177                fast_mode: None,
178                parent,
179                events,
180                result,
181            })
182            .await
183            .is_err()
184        {
185            return Err(NanocodexError::AgentStopped);
186        }
187        Ok(Turn {
188            control: TurnControl {
189                key,
190                commands: self.commands.clone(),
191            },
192            events: event_stream,
193            result: receiver,
194        })
195    }
196
197    /// Changes the reasoning effort for subsequently accepted turns.
198    ///
199    /// An active turn and prompts already queued by the driver retain the
200    /// effort they captured when accepted.
201    ///
202    /// # Errors
203    ///
204    /// Returns an error if the agent driver has stopped.
205    pub async fn set_thinking(&self, thinking: Thinking) -> Result<()> {
206        request_command(&self.commands, |result| Command::SetThinking {
207            thinking,
208            result,
209        })
210        .await
211    }
212
213    /// Enables or disables priority processing for subsequently accepted turns.
214    ///
215    /// An active turn and prompts already queued by the driver retain the mode
216    /// they captured when accepted.
217    ///
218    /// # Errors
219    ///
220    /// Returns an error if the agent driver has stopped.
221    pub async fn set_fast_mode(&self, enabled: bool) -> Result<()> {
222        request_command(&self.commands, |result| Command::SetFastMode {
223            enabled,
224            result,
225        })
226        .await
227    }
228
229    /// Immediately compacts this agent's retained conversation.
230    ///
231    /// Compaction preserves the agent's cache identity, tools, transport, and
232    /// cached project instructions. The next prompt receives a full developer,
233    /// `AGENTS.md`, and environment-context reinjection before its user input.
234    /// If a turn is active, that turn is cancelled and compaction runs before
235    /// prompts that were queued behind it.
236    ///
237    /// ```
238    /// # use nanocodex_agent::{Nanocodex, Result};
239    /// # async fn compact_after_a_turn(agent: &Nanocodex) -> Result<()> {
240    /// agent
241    ///     .prompt("Inspect the parser and explain the failing test.")
242    ///     .await?
243    ///     .result()
244    ///     .await?;
245    /// agent.compact().await?;
246    /// let result = agent
247    ///     .prompt("Now implement the smallest correct parser fix.")
248    ///     .await?
249    ///     .result()
250    ///     .await?;
251    /// assert!(!result.final_message().is_empty());
252    /// # Ok(())
253    /// # }
254    /// ```
255    ///
256    /// # Errors
257    ///
258    /// Returns a model or driver-stopped error. Rollout writes follow the same
259    /// retry-on-[`Self::flush_rollout`] contract as prompt turns.
260    pub async fn compact(&self) -> Result<()> {
261        let parent = tracing::Span::current();
262        let parent = (!parent.is_disabled()).then_some(parent);
263        request_command(&self.commands, |result| Command::Compact { parent, result }).await
264    }
265
266    /// Starts a clean sibling agent with the same private configuration,
267    /// workspace policy, service factory, and tools factory.
268    ///
269    /// The sibling receives a new session, cache lineage, conversation,
270    /// WebSocket, and tool runtime. It does not inherit conversation history.
271    ///
272    /// # Errors
273    ///
274    /// Returns an error after this agent's driver has stopped.
275    pub async fn spawn(&self) -> Result<(Self, AgentEvents)> {
276        request_spawn(&self.commands).await
277    }
278
279    /// Forks from the latest safe model boundary into an independently driven
280    /// agent.
281    ///
282    /// The child receives a fresh WebSocket and tool runtime while sharing the
283    /// immutable transcript, inherited incremental delta, and prompt-cache
284    /// lineage. Partial model output and unmatched tool calls are excluded.
285    ///
286    /// # Errors
287    ///
288    /// Returns an error before the first prompt reaches a safe boundary, or
289    /// when the driver has stopped.
290    pub async fn fork(&self) -> Result<(Self, AgentEvents)> {
291        self.request_fork(None).await
292    }
293
294    /// Forks from an exact historical completed turn while this agent may keep
295    /// advancing on its current branch.
296    ///
297    /// # Errors
298    ///
299    /// Returns an error when the result belongs to another conversation or the
300    /// driver stopped.
301    pub async fn fork_from(&self, completed: &TurnResult) -> Result<(Self, AgentEvents)> {
302        if completed.checkpoint.lineage_id() != self.lineage_id.as_ref() {
303            return Err(NanocodexError::CheckpointLineageMismatch);
304        }
305        self.request_fork(Some(Arc::clone(&completed.checkpoint)))
306            .await
307    }
308
309    async fn request_fork(
310        &self,
311        checkpoint: Option<Arc<CommittedSession>>,
312    ) -> Result<(Self, AgentEvents)> {
313        request_fork(&self.commands, checkpoint).await
314    }
315}
316
317async fn request_fork(
318    commands: &mpsc::Sender<Command>,
319    checkpoint: Option<Arc<CommittedSession>>,
320) -> Result<(Nanocodex, AgentEvents)> {
321    request_command(commands, |result| Command::Fork { checkpoint, result }).await
322}
323
324async fn request_spawn(commands: &mpsc::Sender<Command>) -> Result<(Nanocodex, AgentEvents)> {
325    request_command(commands, |result| Command::Spawn { result }).await
326}
327
328pub(super) async fn request_command<T>(
329    commands: &mpsc::Sender<Command>,
330    command: impl FnOnce(oneshot::Sender<Result<T>>) -> Command,
331) -> Result<T> {
332    let (result, receiver) = oneshot::channel();
333    commands
334        .send(command(result))
335        .await
336        .map_err(|_| NanocodexError::AgentStopped)?;
337    receiver.await.map_err(|_| NanocodexError::AgentStopped)?
338}