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}