Skip to main content

agents_kit/
conversation.rs

1//! Conversation metadata, accepted prompts, exact file reads and durable interactions.
2//!
3//! Creation inputs are passive. Live operations use [`ConversationBackend`]; its
4//! consuming close permanently terminates the conversation. Prompt streams observe
5//! runtime-owned accepted work, so dropping an observer is not cancellation.
6//!
7//! # Streaming, durable approval and explicit cancellation
8//!
9//! This complete Custom journey requires a running Ollama server and the exact
10//! configured model with function-calling support. It is therefore `no_run`.
11//! Replace only the caller configuration to use your intended model; selection
12//! does not substitute a fallback. The caller supplies the executable tool.
13//! Caller-built actions run in process without Kit sandbox enforcement, so Custom
14//! requires `FullAccess`, `Enabled` and read-write roots for this configuration; roots do
15//! not confine the action. This example's `inspect_status` action performs no
16//! filesystem or network I/O.
17//!
18//! Notifications are an exclusive, durable, at-least-once delivery path. Inspect
19//! an Interaction before resolving its exact ID; advancing passive delivery is
20//! acknowledgment, and dropping the receiver is not acknowledgment. The example
21//! deliberately denies every requested action: a production UI can instead
22//! collect a caller decision and send `Allow` or schema-valid `Submit` content.
23//! Denial is a real interaction resolution, not a fabricated tool result.
24//!
25//! The output observer requires the authoritative `Completed` value. Deltas can
26//! include earlier assistant turns around tool execution, so concatenating them
27//! is not an alternative final answer. Observer loss does not abort accepted work;
28//! the separate cancellation portion uses `abort`, then observes settlement.
29//!
30//! ```no_run
31//! use std::{num::NonZeroUsize, time::Duration};
32//! use agents_kit::{AgentBackend, AgentBuilder, AgentStorage, ConversationBackend, WorkspaceBackend};
33//! use agents_kit::config::{AgentConfig, CustomAgentConfig, CustomLoopLimits, CustomToolApproval};
34//! use agents_kit::conversation::NewConversation;
35//! use agents_kit::notification::{ConversationNotificationKind, NotificationResolution};
36//! use agents_kit::prompt::{PromptEvent, PromptRequest};
37//! use agents_kit::{model::ModelSelector, tool::ToolSelection};
38//! use agents_kit::workspace::{NewWorkspace, WorkspaceContext, WorkspaceDefaults,
39//!     WorkspaceExecutionPolicy, WorkspaceNetworkAccess, WorkspaceRoot,
40//!     WorkspaceRootAccess, WorkspaceSandboxMode};
41//! use providers_kit::{Provider, ProviderConfig,
42//!     config::{OllamaConfig, ProviderKind, TransportConfig}};
43//! use storage_kit::{StateStore,
44//!     state::config::{LanceStateStoreConfig, StateStoreConfig}};
45//! use tools_kit::{Toolset, declaration::{ToolInfo, ToolName}, execution::ToolResult,
46//!     local::ToolRegistration, schema::JsonSchema};
47//! # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
48//! # runtime.block_on(async {
49//! let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
50//! let tools = Toolset::local(vec![ToolRegistration::function(
51//!     ToolInfo::new(ToolName::new("inspect_status")?,
52//!         JsonSchema::strict(serde_json::json!({"type": "object", "additionalProperties": false}))),
53//!     |_| async { Ok(ToolResult::json(serde_json::json!({"status": "ready"}))) },
54//! )])?;
55//! let custom = CustomAgentConfig::new(ModelSelector::new("exact-tool-capable-model")?,
56//!     ToolSelection::Auto, CustomLoopLimits::new(4, 4, 1)?, CustomToolApproval::Interaction)?;
57//! let config = AgentConfig::new(custom.into(), Duration::from_secs(60), NonZeroUsize::MIN)?;
58//! let provider = Provider::from_config(ProviderConfig::new(
59//!     ProviderKind::Ollama(OllamaConfig::local()), TransportConfig::default()))?;
60//! let agent = AgentBuilder::create(config, AgentStorage::new(store))
61//!     .with_provider(provider).with_toolset(tools).build().await?;
62//! let outcome = async {
63//!     let cwd = std::env::current_dir()?;
64//!     let context = WorkspaceContext::new(cwd.clone(),
65//!         vec![WorkspaceRoot::new(cwd, WorkspaceRootAccess::ReadWrite)?],
66//!         WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::FullAccess, WorkspaceNetworkAccess::Enabled))?;
67//!     let workspace = agent.new_workspace(NewWorkspace::new("review", context, WorkspaceDefaults::empty())?).await?;
68//!     let conversation = workspace.new_conversation(NewConversation::new(None, None, vec![])?).await?;
69//!     let work = async {
70//!         let mut notifications = conversation.notification_stream()?;
71//!         let request = PromptRequest::new("Call inspect_status, then describe its outcome.")?
72//!             .with_tool_selection(ToolSelection::Required)?
73//!             .with_timeout(Duration::from_secs(90))?;
74//!         let mut output = conversation.prompt_stream(request).await?;
75//!         let completion = tokio::time::timeout(Duration::from_secs(90), async {
76//!             loop {
77//!                 tokio::select! {
78//!                     event = output.recv() => match event {
79//!                         Some(Ok(PromptEvent::OutputDelta { text })) => print!("{text}"),
80//!                         Some(Ok(PromptEvent::Completed(response))) => break Ok(response),
81//!                         Some(Err(error)) => break Err(error),
82//!                         None => break Err(agents_kit::KitError::protocol("completion is missing")),
83//!                     },
84//!                     event = notifications.recv() => {
85//!                         let event = event.ok_or_else(|| agents_kit::KitError::closed("notification stream ended"))??;
86//!                         match event.kind() {
87//!                             ConversationNotificationKind::Interaction { title, body, .. } => {
88//!                                 println!("Decision requested: {title}: {body}");
89//!                                 conversation.resolve_notification(event.id().clone(),
90//!                                     NotificationResolution::Deny { reason: "No action approved by this example".into() }).await?;
91//!                             }
92//!                             _ => println!("Observed passive notification {}", event.id()),
93//!                         }
94//!                     }
95//!                 }
96//!             }
97//!         }).await.map_err(|_| agents_kit::KitError::timeout("caller observation deadline"))??;
98//!         assert!(output.recv().await.is_none());
99//!         assert!(!completion.output().trim().is_empty());
100//!         println!("Authoritative answer: {}", completion.output());
101//!
102//!         // Admission succeeds before cancellation. Dropping `output` would not cancel.
103//!         let mut cancelled = conversation.prompt_stream(PromptRequest::new("Explain the task in detail.")?
104//!             .with_tool_selection(ToolSelection::None)?
105//!             .with_timeout(Duration::from_secs(90))?).await?;
106//!         conversation.abort().await?;
107//!         let terminal = tokio::time::timeout(Duration::from_secs(90), async {
108//!             loop {
109//!                 match cancelled.recv().await {
110//!                     Some(Ok(PromptEvent::OutputDelta { .. })) => {}
111//!                     Some(Ok(PromptEvent::Completed(_))) => break Ok(()), // completion may win the race
112//!                     Some(Err(error)) if error.kind() == agents_kit::error::ErrorKind::Cancelled => break Ok(()),
113//!                     Some(Err(error)) => break Err(error),
114//!                     None => break Err(agents_kit::KitError::protocol("cancellation terminal is missing")),
115//!                 }
116//!             }
117//!         }).await.map_err(|_| agents_kit::KitError::timeout("cancellation observation deadline"))?;
118//!         terminal?;
119//!         Ok::<(), agents_kit::KitError>(())
120//!     }.await;
121//!     let close = conversation.close().await; // also settles work after observation failure
122//!     let workspace_close = workspace.close().await;
123//!     work?;
124//!     close?;
125//!     workspace_close?;
126//!     Ok::<(), Box<dyn std::error::Error>>(())
127//! }.await;
128//! let shutdown = agent.shutdown().await;
129//! outcome?;
130//! shutdown?;
131//! # Ok::<(), Box<dyn std::error::Error>>(())
132//! # })?;
133//! # Ok::<(), Box<dyn std::error::Error>>(())
134//! ```
135//!
136use std::future::Future;
137use std::path::PathBuf;
138use tools_kit::ToolsetBackend;
139
140use providers_kit::ProviderBackend;
141use storage_kit::{DatabaseBackend, StateStoreBackend};
142
143pub use crate::file::FileRead;
144
145use crate::backends::{codex, custom, opencode};
146use crate::prompt::PromptStream;
147use crate::{
148    ConversationId, ConversationNotificationReceiver, KitError, KnowledgeSource, NotificationId,
149    NotificationResolution, PromptRequest, PromptResponse, WorkspaceDefaults, WorkspaceId,
150};
151
152/// Descriptive state for one portable conversation.
153///
154/// # Examples
155///
156/// ```
157/// use agents_kit::conversation::ConversationInfo;
158/// let id = "conversation_00000000000000000000000000000000".parse()?;
159/// let workspace = "workspace_00000000000000000000000000000000".parse()?;
160/// let info = ConversationInfo::new(id, workspace, Some("Review".into()))?;
161/// assert_eq!(info.title(), Some("Review"));
162/// # Ok::<(), agents_kit::KitError>(())
163/// ```
164#[derive(Clone, Debug, Eq, PartialEq)]
165pub struct ConversationInfo {
166    id: ConversationId,
167    workspace_id: WorkspaceId,
168    title: Option<String>,
169}
170
171impl ConversationInfo {
172    /// Validates and constructs descriptive conversation information.
173    ///
174    /// Titles, when present, must be nonempty and must not contain leading or
175    /// trailing whitespace.
176    ///
177    /// # Errors
178    ///
179    /// Returns [`crate::error::ErrorKind::InvalidInput`] when `title` violates the portable
180    /// naming contract.
181    ///
182    /// # Examples
183    ///
184    /// ```
185    /// use agents_kit::{
186    ///     conversation::ConversationInfo,
187    ///     identity::{ConversationId, WorkspaceId},
188    /// };
189    ///
190    /// let id: ConversationId =
191    ///     "conversation_00000000000000000000000000000000".parse()?;
192    /// let workspace_id: WorkspaceId =
193    ///     "workspace_00000000000000000000000000000000".parse()?;
194    /// let info =
195    ///     ConversationInfo::new(id, workspace_id, Some("release".to_owned()))?;
196    /// assert_eq!(info.title(), Some("release"));
197    /// # Ok::<(), agents_kit::KitError>(())
198    /// ```
199    pub fn new(
200        id: ConversationId,
201        workspace_id: WorkspaceId,
202        title: Option<String>,
203    ) -> Result<Self, KitError> {
204        Self::validate_title(title.as_deref())?;
205        Ok(Self {
206            id,
207            workspace_id,
208            title,
209        })
210    }
211
212    /// Returns the portable conversation identifier.
213    ///
214    /// # Examples
215    ///
216    /// ```
217    /// use agents_kit::conversation::ConversationInfo;
218    /// let id = "conversation_00000000000000000000000000000000".parse()?;
219    /// let workspace = "workspace_00000000000000000000000000000000".parse()?;
220    /// let info = ConversationInfo::new(id, workspace, Some("Review".into()))?;
221    /// assert!(info.id().as_str().starts_with("conversation_"));
222    /// # Ok::<(), agents_kit::KitError>(())
223    /// ```
224    pub fn id(&self) -> &ConversationId {
225        &self.id
226    }
227
228    /// Returns the portable identifier of the owning workspace.
229    ///
230    /// # Examples
231    ///
232    /// ```
233    /// use agents_kit::conversation::ConversationInfo;
234    /// let id = "conversation_00000000000000000000000000000000".parse()?;
235    /// let workspace = "workspace_00000000000000000000000000000000".parse()?;
236    /// let info = ConversationInfo::new(id, workspace, Some("Review".into()))?;
237    /// assert!(info.workspace_id().as_str().starts_with("workspace_"));
238    /// # Ok::<(), agents_kit::KitError>(())
239    /// ```
240    pub fn workspace_id(&self) -> &WorkspaceId {
241        &self.workspace_id
242    }
243
244    /// Returns the configured title, when present.
245    ///
246    /// # Examples
247    ///
248    /// ```
249    /// use agents_kit::conversation::ConversationInfo;
250    /// let id = "conversation_00000000000000000000000000000000".parse()?;
251    /// let workspace = "workspace_00000000000000000000000000000000".parse()?;
252    /// let info = ConversationInfo::new(id, workspace, Some("Review".into()))?;
253    /// assert_eq!(info.title(), Some("Review"));
254    /// # Ok::<(), agents_kit::KitError>(())
255    /// ```
256    pub fn title(&self) -> Option<&str> {
257        self.title.as_deref()
258    }
259
260    fn validate_title(title: Option<&str>) -> Result<(), KitError> {
261        if title.is_some_and(|value| value.is_empty() || value.trim() != value) {
262            return Err(KitError::invalid_input(
263                "conversation title must be nonempty and unpadded when present",
264            ));
265        }
266        Ok(())
267    }
268}
269
270/// Validated inputs for creating a conversation.
271///
272/// # Examples
273///
274/// ```
275/// use agents_kit::conversation::NewConversation;
276///
277/// let request = NewConversation::new(
278///     Some("incident review".to_owned()),
279///     None,
280///     Vec::new(),
281/// )?;
282/// assert_eq!(request.title(), Some("incident review"));
283/// # Ok::<(), agents_kit::KitError>(())
284/// ```
285#[derive(Clone, Debug, Eq, PartialEq)]
286pub struct NewConversation {
287    title: Option<String>,
288    defaults: Option<WorkspaceDefaults>,
289    knowledge_sources: Vec<KnowledgeSource>,
290}
291
292impl NewConversation {
293    /// Validates and constructs conversation-creation inputs.
294    ///
295    /// # Errors
296    ///
297    /// Returns `InvalidInput` for an empty or padded title, an invalid knowledge policy/source, or duplicate source IDs. Construction performs no native I/O.
298    ///
299    /// # Examples
300    ///
301    /// ```
302    /// use agents_kit::conversation::NewConversation;
303    /// let request = NewConversation::new(Some("Review".into()), None, Vec::new())?;
304    /// assert_eq!(request.title(), Some("Review"));
305    /// assert!(request.defaults().is_none());
306    /// assert!(request.knowledge_sources().is_empty());
307    /// # Ok::<(), agents_kit::KitError>(())
308    /// ```
309    pub fn new(
310        title: Option<String>,
311        defaults: Option<WorkspaceDefaults>,
312        knowledge_sources: Vec<KnowledgeSource>,
313    ) -> Result<Self, KitError> {
314        ConversationInfo::validate_title(title.as_deref())?;
315        if let Some(defaults) = &defaults
316            && let Some(policy) = defaults.knowledge_policy()
317        {
318            policy.validate()?;
319        }
320        let mut ids = std::collections::BTreeSet::new();
321        for source in &knowledge_sources {
322            source.validate()?;
323            if !ids.insert(source.source_id()) {
324                return Err(KitError::invalid_input(
325                    "conversation knowledge source ids must be unique",
326                ));
327            }
328        }
329        Ok(Self {
330            title,
331            defaults,
332            knowledge_sources,
333        })
334    }
335
336    /// Returns the requested title, when present.
337    ///
338    /// # Examples
339    ///
340    /// ```
341    /// use agents_kit::conversation::NewConversation;
342    /// let request = NewConversation::new(Some("Review".into()), None, Vec::new())?;
343    /// assert_eq!(request.title(), Some("Review"));
344    /// assert!(request.defaults().is_none());
345    /// assert!(request.knowledge_sources().is_empty());
346    /// # Ok::<(), agents_kit::KitError>(())
347    /// ```
348    pub fn title(&self) -> Option<&str> {
349        self.title.as_deref()
350    }
351
352    /// Returns conversation-level default overrides, when present.
353    ///
354    /// # Examples
355    ///
356    /// ```
357    /// use agents_kit::conversation::NewConversation;
358    /// let request = NewConversation::new(Some("Review".into()), None, Vec::new())?;
359    /// assert_eq!(request.title(), Some("Review"));
360    /// assert!(request.defaults().is_none());
361    /// assert!(request.knowledge_sources().is_empty());
362    /// # Ok::<(), agents_kit::KitError>(())
363    /// ```
364    pub fn defaults(&self) -> Option<&WorkspaceDefaults> {
365        self.defaults.as_ref()
366    }
367
368    /// Returns the initial managed-knowledge sources.
369    ///
370    /// # Examples
371    ///
372    /// ```
373    /// use agents_kit::conversation::NewConversation;
374    /// let request = NewConversation::new(Some("Review".into()), None, Vec::new())?;
375    /// assert_eq!(request.title(), Some("Review"));
376    /// assert!(request.defaults().is_none());
377    /// assert!(request.knowledge_sources().is_empty());
378    /// # Ok::<(), agents_kit::KitError>(())
379    /// ```
380    pub fn knowledge_sources(&self) -> &[KnowledgeSource] {
381        &self.knowledge_sources
382    }
383}
384
385/// Public implementation contract for one live portable conversation.
386///
387/// A caller workspace returns its own concrete conversation implementation through
388/// [`WorkspaceBackend::Conversation`](crate::WorkspaceBackend::Conversation).
389/// Runtime composition can instead use a caller-owned exhaustive enum hierarchy.
390/// Prompting reaches a terminal portable result, abort joins active work, and close
391/// permanently closes the durable conversation.
392///
393/// # Examples
394///
395/// Local construction and metadata use no provider requests. The runtime stays
396/// alive through consuming conversation, workspace and Agent cleanup.
397///
398/// ```
399/// # use agents_kit::{AgentBackend, AgentBuilder, AgentStorage, WorkspaceBackend, ConversationBackend};
400/// # use agents_kit::config::{AgentConfig, CustomAgentConfig, CustomLoopLimits, CustomToolApproval};
401/// # use agents_kit::{model::ModelSelector, tool::ToolSelection};
402/// # use agents_kit::workspace::{NewWorkspace, WorkspaceContext, WorkspaceDefaults, WorkspaceRoot, WorkspaceRootAccess, WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
403/// # use agents_kit::conversation::NewConversation;
404/// # use providers_kit::{Provider, ProviderConfig, config::{ProviderKind, OllamaConfig, TransportConfig}};
405/// # use storage_kit::{StateStore, state::config::{StateStoreConfig, LanceStateStoreConfig}};
406/// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
407/// # runtime.block_on(async {
408/// #     let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
409/// #     let custom = CustomAgentConfig::new(ModelSelector::new("exact-model")?, ToolSelection::None,
410/// #         CustomLoopLimits::new(4, 0, 1)?, CustomToolApproval::Deny)?;
411/// #     let config = AgentConfig::new(custom.into(), std::time::Duration::from_secs(10), std::num::NonZeroUsize::MIN)?;
412/// #     let provider = Provider::from_config(ProviderConfig::new(
413/// #         ProviderKind::Ollama(OllamaConfig::local()), TransportConfig::default()))?;
414/// #     let agent = AgentBuilder::create(config, AgentStorage::new(store)).with_provider(provider).build().await?;
415/// #     let cwd = std::env::current_dir()?;
416/// #     let context = WorkspaceContext::new(cwd.clone(), vec![WorkspaceRoot::new(cwd, WorkspaceRootAccess::ReadOnly)?],
417/// #         WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
418/// #     let workspace = agent.new_workspace(NewWorkspace::new("docs", context, WorkspaceDefaults::empty())?).await?;
419/// #     let conversation = workspace.new_conversation(NewConversation::new(Some("Review".into()), None, Vec::new())?).await?;
420/// let info = conversation.info().await?;
421/// assert_eq!(info.id(), &conversation.id());
422/// #     conversation.close().await?;
423/// #     workspace.close().await?;
424/// #     agent.shutdown().await?;
425/// #     Ok::<(), Box<dyn std::error::Error>>(())
426/// # })?;
427/// # Ok::<(), Box<dyn std::error::Error>>(())
428/// ```
429pub trait ConversationBackend: Send + Sync + 'static {
430    /// Returns the durable portable conversation identity.
431    ///
432    /// # Examples
433    ///
434    /// This local Custom conversation uses memory Lance and performs no inference.
435    ///
436    /// ```
437    /// # use agents_kit::{AgentBackend, AgentBuilder, AgentStorage, WorkspaceBackend, ConversationBackend};
438    /// # use agents_kit::config::{AgentConfig, CustomAgentConfig, CustomLoopLimits, CustomToolApproval};
439    /// # use agents_kit::{model::ModelSelector, tool::ToolSelection};
440    /// # use agents_kit::workspace::{NewWorkspace, WorkspaceContext, WorkspaceDefaults, WorkspaceRoot, WorkspaceRootAccess, WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
441    /// # use agents_kit::conversation::NewConversation;
442    /// # use providers_kit::{Provider, ProviderConfig, config::{ProviderKind, OllamaConfig, TransportConfig}};
443    /// # use storage_kit::{StateStore, state::config::{StateStoreConfig, LanceStateStoreConfig}};
444    /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
445    /// # runtime.block_on(async {
446    /// #     let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
447    /// #     let custom = CustomAgentConfig::new(ModelSelector::new("exact-model")?, ToolSelection::None,
448    /// #         CustomLoopLimits::new(4, 0, 1)?, CustomToolApproval::Deny)?;
449    /// #     let config = AgentConfig::new(custom.into(), std::time::Duration::from_secs(10), std::num::NonZeroUsize::MIN)?;
450    /// #     let provider = Provider::from_config(ProviderConfig::new(
451    /// #         ProviderKind::Ollama(OllamaConfig::local()), TransportConfig::default()))?;
452    /// #     let agent = AgentBuilder::create(config, AgentStorage::new(store)).with_provider(provider).build().await?;
453    /// #     let cwd = std::env::current_dir()?;
454    /// #     let context = WorkspaceContext::new(cwd.clone(), vec![WorkspaceRoot::new(cwd, WorkspaceRootAccess::ReadOnly)?],
455    /// #         WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
456    /// #     let workspace = agent.new_workspace(NewWorkspace::new("docs", context, WorkspaceDefaults::empty())?).await?;
457    /// #     let conversation = workspace.new_conversation(NewConversation::new(Some("Review".into()), None, Vec::new())?).await?;
458    /// let id = conversation.id();
459    /// assert!(id.as_str().starts_with("conversation_"));
460    /// #     conversation.close().await?;
461    /// #     workspace.close().await?;
462    /// #     agent.shutdown().await?;
463    /// #     Ok::<(), Box<dyn std::error::Error>>(())
464    /// # })?;
465    /// # Ok::<(), Box<dyn std::error::Error>>(())
466    /// ```
467    fn id(&self) -> ConversationId;
468
469    /// Reads the current descriptive conversation state.
470    ///
471    /// # Errors
472    ///
473    /// Returns `Closed` for a terminal handle, `Conflict` for stale ownership/revision, or a typed storage/backend failure.
474    ///
475    /// # Examples
476    ///
477    /// This local Custom conversation uses memory Lance and performs no inference.
478    ///
479    /// ```
480    /// # use agents_kit::{AgentBackend, AgentBuilder, AgentStorage, WorkspaceBackend, ConversationBackend};
481    /// # use agents_kit::config::{AgentConfig, CustomAgentConfig, CustomLoopLimits, CustomToolApproval};
482    /// # use agents_kit::{model::ModelSelector, tool::ToolSelection};
483    /// # use agents_kit::workspace::{NewWorkspace, WorkspaceContext, WorkspaceDefaults, WorkspaceRoot, WorkspaceRootAccess, WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
484    /// # use agents_kit::conversation::NewConversation;
485    /// # use providers_kit::{Provider, ProviderConfig, config::{ProviderKind, OllamaConfig, TransportConfig}};
486    /// # use storage_kit::{StateStore, state::config::{StateStoreConfig, LanceStateStoreConfig}};
487    /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
488    /// # runtime.block_on(async {
489    /// #     let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
490    /// #     let custom = CustomAgentConfig::new(ModelSelector::new("exact-model")?, ToolSelection::None,
491    /// #         CustomLoopLimits::new(4, 0, 1)?, CustomToolApproval::Deny)?;
492    /// #     let config = AgentConfig::new(custom.into(), std::time::Duration::from_secs(10), std::num::NonZeroUsize::MIN)?;
493    /// #     let provider = Provider::from_config(ProviderConfig::new(
494    /// #         ProviderKind::Ollama(OllamaConfig::local()), TransportConfig::default()))?;
495    /// #     let agent = AgentBuilder::create(config, AgentStorage::new(store)).with_provider(provider).build().await?;
496    /// #     let cwd = std::env::current_dir()?;
497    /// #     let context = WorkspaceContext::new(cwd.clone(), vec![WorkspaceRoot::new(cwd, WorkspaceRootAccess::ReadOnly)?],
498    /// #         WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
499    /// #     let workspace = agent.new_workspace(NewWorkspace::new("docs", context, WorkspaceDefaults::empty())?).await?;
500    /// #     let conversation = workspace.new_conversation(NewConversation::new(Some("Review".into()), None, Vec::new())?).await?;
501    /// let info = conversation.info().await?;
502    /// assert_eq!(info.id(), &conversation.id());
503    /// assert_eq!(info.title(), Some("Review"));
504    /// #     conversation.close().await?;
505    /// #     workspace.close().await?;
506    /// #     agent.shutdown().await?;
507    /// #     Ok::<(), Box<dyn std::error::Error>>(())
508    /// # })?;
509    /// # Ok::<(), Box<dyn std::error::Error>>(())
510    /// ```
511    fn info(&self) -> impl Future<Output = Result<ConversationInfo, KitError>> + Send + '_;
512
513    /// Runs one foreground prompt through its terminal portable result.
514    ///
515    /// Unary and incremental calls share one accepted prompt owner. Dropping the waiter does not cancel accepted work or free its slot. Success retains exact authored text; structured output is separately validated and usage is present only when sourceable.
516    ///
517    /// # Errors
518    ///
519    /// Returns `InvalidInput`/`Unsupported` before acceptance for invalid or unsupported requests, `Busy` when admission is occupied, or typed cancellation, deadline, conflict, storage, and backend failures. After ambiguous acceptance, do not blindly retry: cancellation/reconciliation retains the generation fence until settlement.
520    ///
521    /// # Examples
522    ///
523    /// Requires a constructed live conversation and its configured native process or provider.
524    ///
525    /// ```no_run
526    /// use agents_kit::{ConversationBackend, KitError};
527    /// async fn example<C: ConversationBackend>(conversation: &C) -> Result<(), KitError> {
528    ///     let request = agents_kit::prompt::PromptRequest::new("Describe the current task")?;
529    ///     let response = conversation.prompt(request).await?;
530    ///     println!("{}", response.output());
531    ///     Ok(())
532    /// }
533    /// ```
534    fn prompt(
535        &self,
536        request: PromptRequest,
537    ) -> impl Future<Output = Result<PromptResponse, KitError>> + Send + '_;
538
539    /// Starts real incremental observation of one foreground prompt.
540    ///
541    /// The default rejects unsupported streaming before accepting work. Override
542    /// using public [`PromptStream`] and [`PromptEvent`](crate::prompt::PromptEvent)
543    /// values; do not fabricate deltas from a terminal response. Dropping an
544    /// observer does not cancel accepted work or release the conversation slot.
545    ///
546    /// # Errors
547    ///
548    /// Returns `Unsupported` when the backend has no genuine incremental route, or the same admission failures as `prompt`. After admission, the stream carries terminal failures; premature source EOF is a protocol failure.
549    ///
550    /// # Examples
551    ///
552    /// Requires a constructed live conversation and its configured native process or provider.
553    ///
554    /// ```no_run
555    /// use agents_kit::{ConversationBackend, KitError};
556    /// async fn example<C: ConversationBackend>(conversation: &C) -> Result<(), KitError> {
557    ///     use agents_kit::prompt::{PromptRequest, PromptEvent};
558    ///     let mut stream = conversation.prompt_stream(PromptRequest::new("Describe the current task")?).await?;
559    ///     while let Some(event) = stream.recv().await {
560    ///         match event? {
561    ///             PromptEvent::OutputDelta { text } => print!("{text}"),
562    ///             PromptEvent::Completed(response) => println!("\nFinal: {}", response.output()),
563    ///         }
564    ///     }
565    ///     Ok(())
566    /// }
567    /// ```
568    fn prompt_stream(
569        &self,
570        _request: PromptRequest,
571    ) -> impl Future<Output = Result<PromptStream, KitError>> + Send + '_ {
572        async {
573            Err(KitError::unsupported(
574                "incremental prompt output is unavailable",
575            ))
576        }
577    }
578
579    /// Aborts the active prompt, or succeeds when no prompt is active.
580    ///
581    /// Cancellation targets only the active generation and joins its settlement. Natural completion may win; abort does not replace the prompt waiter or return its answer.
582    ///
583    /// # Errors
584    ///
585    /// Returns a typed cancellation, timeout, conflict, lifecycle, or backend failure. Ambiguous cancellation retains recovery ownership; an error does not prove remote work stopped.
586    ///
587    /// # Examples
588    ///
589    /// This local Custom conversation uses memory Lance and performs no inference.
590    ///
591    /// ```
592    /// # use agents_kit::{AgentBackend, AgentBuilder, AgentStorage, WorkspaceBackend, ConversationBackend};
593    /// # use agents_kit::config::{AgentConfig, CustomAgentConfig, CustomLoopLimits, CustomToolApproval};
594    /// # use agents_kit::{model::ModelSelector, tool::ToolSelection};
595    /// # use agents_kit::workspace::{NewWorkspace, WorkspaceContext, WorkspaceDefaults, WorkspaceRoot, WorkspaceRootAccess, WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
596    /// # use agents_kit::conversation::NewConversation;
597    /// # use providers_kit::{Provider, ProviderConfig, config::{ProviderKind, OllamaConfig, TransportConfig}};
598    /// # use storage_kit::{StateStore, state::config::{StateStoreConfig, LanceStateStoreConfig}};
599    /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
600    /// # runtime.block_on(async {
601    /// #     let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
602    /// #     let custom = CustomAgentConfig::new(ModelSelector::new("exact-model")?, ToolSelection::None,
603    /// #         CustomLoopLimits::new(4, 0, 1)?, CustomToolApproval::Deny)?;
604    /// #     let config = AgentConfig::new(custom.into(), std::time::Duration::from_secs(10), std::num::NonZeroUsize::MIN)?;
605    /// #     let provider = Provider::from_config(ProviderConfig::new(
606    /// #         ProviderKind::Ollama(OllamaConfig::local()), TransportConfig::default()))?;
607    /// #     let agent = AgentBuilder::create(config, AgentStorage::new(store)).with_provider(provider).build().await?;
608    /// #     let cwd = std::env::current_dir()?;
609    /// #     let context = WorkspaceContext::new(cwd.clone(), vec![WorkspaceRoot::new(cwd, WorkspaceRootAccess::ReadOnly)?],
610    /// #         WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
611    /// #     let workspace = agent.new_workspace(NewWorkspace::new("docs", context, WorkspaceDefaults::empty())?).await?;
612    /// #     let conversation = workspace.new_conversation(NewConversation::new(Some("Review".into()), None, Vec::new())?).await?;
613    /// // Idle abort is local and does not make a model request.
614    /// conversation.abort().await?;
615    /// #     conversation.close().await?;
616    /// #     workspace.close().await?;
617    /// #     agent.shutdown().await?;
618    /// #     Ok::<(), Box<dyn std::error::Error>>(())
619    /// # })?;
620    /// # Ok::<(), Box<dyn std::error::Error>>(())
621    /// ```
622    fn abort(&self) -> impl Future<Output = Result<(), KitError>> + Send + '_;
623
624    /// Reads a validated workspace-relative file through the implementation.
625    ///
626    /// The path is interpreted against the admitted workspace cwd on the backend host. Absolute paths, dot/parent components and repeated separators are rejected rather than normalized.
627    ///
628    /// # Errors
629    ///
630    /// Returns `InvalidInput` for a nonportable relative path, `Unsupported` for a route unable to preserve exact bytes/policy, or a typed revision, lifecycle, storage, or native read failure. An untyped native rejection is not evidence of `NotFound`.
631    ///
632    /// # Examples
633    ///
634    /// Requires a constructed live conversation and its configured native process or provider.
635    ///
636    /// ```no_run
637    /// use agents_kit::{ConversationBackend, KitError};
638    /// async fn example<C: ConversationBackend>(conversation: &C) -> Result<(), KitError> {
639    ///     let file = conversation.read_file("README.md".into()).await?;
640    ///     assert_eq!(file.path(), std::path::Path::new("README.md"));
641    ///     let size = file.text().map(str::len).or_else(|| file.bytes().map(<[u8]>::len));
642    ///     assert!(size.is_some());
643    ///     Ok(())
644    /// }
645    /// ```
646    fn read_file(
647        &self,
648        path: PathBuf,
649    ) -> impl Future<Output = Result<FileRead, KitError>> + Send + '_;
650
651    /// Claims the conversation's exclusive durable notification receiver.
652    ///
653    /// # Errors
654    ///
655    /// Returns `Busy` while another receiver owns the lease, `Closed` for a closed handle, or a typed admission failure. Later durable delivery failures are yielded by `recv`.
656    ///
657    /// # Examples
658    ///
659    /// This local Custom conversation uses memory Lance and performs no inference.
660    ///
661    /// ```
662    /// # use agents_kit::{AgentBackend, AgentBuilder, AgentStorage, WorkspaceBackend, ConversationBackend};
663    /// # use agents_kit::config::{AgentConfig, CustomAgentConfig, CustomLoopLimits, CustomToolApproval};
664    /// # use agents_kit::{model::ModelSelector, tool::ToolSelection};
665    /// # use agents_kit::workspace::{NewWorkspace, WorkspaceContext, WorkspaceDefaults, WorkspaceRoot, WorkspaceRootAccess, WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
666    /// # use agents_kit::conversation::NewConversation;
667    /// # use providers_kit::{Provider, ProviderConfig, config::{ProviderKind, OllamaConfig, TransportConfig}};
668    /// # use storage_kit::{StateStore, state::config::{StateStoreConfig, LanceStateStoreConfig}};
669    /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
670    /// # runtime.block_on(async {
671    /// #     let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
672    /// #     let custom = CustomAgentConfig::new(ModelSelector::new("exact-model")?, ToolSelection::None,
673    /// #         CustomLoopLimits::new(4, 0, 1)?, CustomToolApproval::Deny)?;
674    /// #     let config = AgentConfig::new(custom.into(), std::time::Duration::from_secs(10), std::num::NonZeroUsize::MIN)?;
675    /// #     let provider = Provider::from_config(ProviderConfig::new(
676    /// #         ProviderKind::Ollama(OllamaConfig::local()), TransportConfig::default()))?;
677    /// #     let agent = AgentBuilder::create(config, AgentStorage::new(store)).with_provider(provider).build().await?;
678    /// #     let cwd = std::env::current_dir()?;
679    /// #     let context = WorkspaceContext::new(cwd.clone(), vec![WorkspaceRoot::new(cwd, WorkspaceRootAccess::ReadOnly)?],
680    /// #         WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
681    /// #     let workspace = agent.new_workspace(NewWorkspace::new("docs", context, WorkspaceDefaults::empty())?).await?;
682    /// #     let conversation = workspace.new_conversation(NewConversation::new(Some("Review".into()), None, Vec::new())?).await?;
683    /// let receiver = conversation.notification_stream()?;
684    /// drop(receiver); // release without acknowledging anything
685    /// let replacement = conversation.notification_stream()?;
686    /// drop(replacement);
687    /// #     conversation.close().await?;
688    /// #     workspace.close().await?;
689    /// #     agent.shutdown().await?;
690    /// #     Ok::<(), Box<dyn std::error::Error>>(())
691    /// # })?;
692    /// # Ok::<(), Box<dyn std::error::Error>>(())
693    /// ```
694    fn notification_stream(&self) -> Result<ConversationNotificationReceiver, KitError>;
695
696    /// Submits a resolution for an unresolved portable notification.
697    ///
698    /// # Errors
699    ///
700    /// Returns `InvalidInput` for an invalid resolution, `NotFound` for an unknown notification, or typed lifecycle, conflict, storage, and native resolution failures. Intent is durable before dispatch; known or ambiguous acceptance is never blindly resent.
701    ///
702    /// # Examples
703    ///
704    /// Requires a constructed live conversation and its configured native process or provider.
705    ///
706    /// ```no_run
707    /// use agents_kit::{ConversationBackend, KitError};
708    /// async fn example<C: ConversationBackend>(conversation: &C, id: agents_kit::identity::NotificationId) -> Result<(), KitError> {
709    ///     use agents_kit::notification::NotificationResolution;
710    ///     // The caller has inspected this pending approval and chosen denial.
711    ///     conversation.resolve_notification(id, NotificationResolution::Deny {
712    ///         reason: "Outside the approved task".into(),
713    ///     }).await?;
714    ///     Ok(())
715    /// }
716    /// ```
717    fn resolve_notification(
718        &self,
719        id: NotificationId,
720        resolution: NotificationResolution,
721    ) -> impl Future<Output = Result<(), KitError>> + Send + '_;
722
723    /// Permanently closes the conversation and consumes the implementation.
724    ///
725    /// Unlike parent workspace close or Agent shutdown, this permanently closes the durable conversation. Admission is sealed and active work, interactions, knowledge and owned resources are settled before completion.
726    ///
727    /// # Errors
728    ///
729    /// Returns a typed cancellation, reconciliation, storage, or cleanup failure. The handle remains consumed on failure; durable recovery obligations remain authoritative.
730    ///
731    /// # Examples
732    ///
733    /// This local Custom conversation uses memory Lance and performs no inference.
734    ///
735    /// ```
736    /// # use agents_kit::{AgentBackend, AgentBuilder, AgentStorage, WorkspaceBackend, ConversationBackend};
737    /// # use agents_kit::config::{AgentConfig, CustomAgentConfig, CustomLoopLimits, CustomToolApproval};
738    /// # use agents_kit::{model::ModelSelector, tool::ToolSelection};
739    /// # use agents_kit::workspace::{NewWorkspace, WorkspaceContext, WorkspaceDefaults, WorkspaceRoot, WorkspaceRootAccess, WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
740    /// # use agents_kit::conversation::NewConversation;
741    /// # use providers_kit::{Provider, ProviderConfig, config::{ProviderKind, OllamaConfig, TransportConfig}};
742    /// # use storage_kit::{StateStore, state::config::{StateStoreConfig, LanceStateStoreConfig}};
743    /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
744    /// # runtime.block_on(async {
745    /// #     let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
746    /// #     let custom = CustomAgentConfig::new(ModelSelector::new("exact-model")?, ToolSelection::None,
747    /// #         CustomLoopLimits::new(4, 0, 1)?, CustomToolApproval::Deny)?;
748    /// #     let config = AgentConfig::new(custom.into(), std::time::Duration::from_secs(10), std::num::NonZeroUsize::MIN)?;
749    /// #     let provider = Provider::from_config(ProviderConfig::new(
750    /// #         ProviderKind::Ollama(OllamaConfig::local()), TransportConfig::default()))?;
751    /// #     let agent = AgentBuilder::create(config, AgentStorage::new(store)).with_provider(provider).build().await?;
752    /// #     let cwd = std::env::current_dir()?;
753    /// #     let context = WorkspaceContext::new(cwd.clone(), vec![WorkspaceRoot::new(cwd, WorkspaceRootAccess::ReadOnly)?],
754    /// #         WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
755    /// #     let workspace = agent.new_workspace(NewWorkspace::new("docs", context, WorkspaceDefaults::empty())?).await?;
756    /// #     let conversation = workspace.new_conversation(NewConversation::new(Some("Review".into()), None, Vec::new())?).await?;
757    /// conversation.close().await?; // consuming terminal cleanup
758    /// #     workspace.close().await?;
759    /// #     agent.shutdown().await?;
760    /// #     Ok::<(), Box<dyn std::error::Error>>(())
761    /// # })?;
762    /// # Ok::<(), Box<dyn std::error::Error>>(())
763    /// ```
764    fn close(self) -> impl Future<Output = Result<(), KitError>> + Send
765    where
766        Self: Sized;
767}
768
769/// Opaque live handle for one portable conversation.
770///
771/// # Examples
772///
773/// Local Custom construction needs no running provider until a prompt is submitted.
774///
775/// ```
776/// # use agents_kit::{AgentBackend, AgentBuilder, AgentStorage, WorkspaceBackend, ConversationBackend};
777/// # use agents_kit::config::{AgentConfig, CustomAgentConfig, CustomLoopLimits, CustomToolApproval};
778/// # use agents_kit::{model::ModelSelector, tool::ToolSelection};
779/// # use agents_kit::workspace::{NewWorkspace, WorkspaceContext, WorkspaceDefaults, WorkspaceRoot, WorkspaceRootAccess, WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
780/// # use agents_kit::conversation::NewConversation;
781/// # use providers_kit::{Provider, ProviderConfig, config::{ProviderKind, OllamaConfig, TransportConfig}};
782/// # use storage_kit::{StateStore, state::config::{StateStoreConfig, LanceStateStoreConfig}};
783/// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
784/// # runtime.block_on(async {
785/// #     let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
786/// #     let custom = CustomAgentConfig::new(ModelSelector::new("exact-model")?, ToolSelection::None,
787/// #         CustomLoopLimits::new(4, 0, 1)?, CustomToolApproval::Deny)?;
788/// #     let config = AgentConfig::new(custom.into(), std::time::Duration::from_secs(10), std::num::NonZeroUsize::MIN)?;
789/// #     let provider = Provider::from_config(ProviderConfig::new(
790/// #         ProviderKind::Ollama(OllamaConfig::local()), TransportConfig::default()))?;
791/// #     let agent = AgentBuilder::create(config, AgentStorage::new(store)).with_provider(provider).build().await?;
792/// #     let cwd = std::env::current_dir()?;
793/// #     let context = WorkspaceContext::new(cwd.clone(), vec![WorkspaceRoot::new(cwd, WorkspaceRootAccess::ReadOnly)?],
794/// #         WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
795/// #     let workspace = agent.new_workspace(NewWorkspace::new("docs", context, WorkspaceDefaults::empty())?).await?;
796/// #     let conversation = workspace.new_conversation(NewConversation::new(Some("Review".into()), None, Vec::new())?).await?;
797/// assert_eq!(conversation.info().await?.title(), Some("Review"));
798/// #     conversation.close().await?;
799/// #     workspace.close().await?;
800/// #     agent.shutdown().await?;
801/// #     Ok::<(), Box<dyn std::error::Error>>(())
802/// # })?;
803/// # Ok::<(), Box<dyn std::error::Error>>(())
804/// ```
805pub struct Conversation<
806    S: StateStoreBackend = storage_kit::StateStore,
807    D: DatabaseBackend = storage_kit::Database,
808    P: ProviderBackend = providers_kit::Provider,
809    T: ToolsetBackend = tools_kit::Toolset,
810> {
811    inner: ConversationInner<S, D, P, T>,
812}
813
814enum ConversationInner<
815    S: StateStoreBackend,
816    D: DatabaseBackend,
817    P: ProviderBackend,
818    T: ToolsetBackend,
819> {
820    Codex(codex::conversations::CodexConversation<S, D, T>),
821    OpenCode(opencode::conversations::OpenCodeConversation<S, D>),
822    Custom(custom::CustomConversation<S, D, P, T>),
823}
824
825impl<S, D, P, T> Conversation<S, D, P, T>
826where
827    S: StateStoreBackend,
828    D: DatabaseBackend,
829    P: ProviderBackend,
830    T: ToolsetBackend,
831{
832    pub(crate) fn codex(backend: codex::conversations::CodexConversation<S, D, T>) -> Self {
833        Self {
834            inner: ConversationInner::Codex(backend),
835        }
836    }
837
838    pub(crate) fn opencode(backend: opencode::conversations::OpenCodeConversation<S, D>) -> Self {
839        Self {
840            inner: ConversationInner::OpenCode(backend),
841        }
842    }
843
844    pub(crate) fn custom(backend: custom::CustomConversation<S, D, P, T>) -> Self {
845        Self {
846            inner: ConversationInner::Custom(backend),
847        }
848    }
849}
850
851impl<S, D, P, T> ConversationBackend for ConversationInner<S, D, P, T>
852where
853    S: StateStoreBackend,
854    D: DatabaseBackend,
855    P: ProviderBackend,
856    T: ToolsetBackend,
857{
858    fn id(&self) -> ConversationId {
859        match self {
860            ConversationInner::Codex(backend) => backend.id(),
861            ConversationInner::OpenCode(backend) => backend.id(),
862            ConversationInner::Custom(backend) => backend.id(),
863        }
864    }
865
866    async fn info(&self) -> Result<ConversationInfo, KitError> {
867        match self {
868            ConversationInner::Codex(backend) => backend.info().await,
869            ConversationInner::OpenCode(backend) => backend.info().await,
870            ConversationInner::Custom(backend) => backend.info().await,
871        }
872    }
873
874    async fn prompt(&self, request: PromptRequest) -> Result<PromptResponse, KitError> {
875        match self {
876            ConversationInner::Codex(backend) => backend.prompt(request).await,
877            ConversationInner::OpenCode(backend) => backend.prompt(request).await,
878            ConversationInner::Custom(backend) => backend.prompt(request).await,
879        }
880    }
881
882    async fn prompt_stream(&self, request: PromptRequest) -> Result<PromptStream, KitError> {
883        match self {
884            ConversationInner::Codex(backend) => backend.prompt_stream(request).await,
885            ConversationInner::OpenCode(backend) => backend.prompt_stream(request).await,
886            ConversationInner::Custom(backend) => backend.prompt_stream(request).await,
887        }
888    }
889
890    async fn abort(&self) -> Result<(), KitError> {
891        match self {
892            ConversationInner::Codex(backend) => backend.abort().await,
893            ConversationInner::OpenCode(backend) => backend.abort().await,
894            ConversationInner::Custom(backend) => backend.abort().await,
895        }
896    }
897
898    async fn read_file(&self, path: PathBuf) -> Result<FileRead, KitError> {
899        match self {
900            ConversationInner::Codex(backend) => backend.read_file(path).await,
901            ConversationInner::OpenCode(backend) => backend.read_file(path).await,
902            ConversationInner::Custom(backend) => backend.read_file(path).await,
903        }
904    }
905
906    fn notification_stream(&self) -> Result<ConversationNotificationReceiver, KitError> {
907        match self {
908            ConversationInner::Codex(backend) => backend.notification_stream(),
909            ConversationInner::OpenCode(backend) => backend.notification_stream(),
910            ConversationInner::Custom(backend) => backend.notification_stream(),
911        }
912    }
913
914    async fn resolve_notification(
915        &self,
916        id: NotificationId,
917        resolution: NotificationResolution,
918    ) -> Result<(), KitError> {
919        match self {
920            ConversationInner::Codex(backend) => backend.resolve_notification(id, resolution).await,
921            ConversationInner::OpenCode(backend) => {
922                backend.resolve_notification(id, resolution).await
923            }
924            ConversationInner::Custom(backend) => {
925                backend.resolve_notification(id, resolution).await
926            }
927        }
928    }
929
930    async fn close(self) -> Result<(), KitError> {
931        match self {
932            ConversationInner::Codex(backend) => backend.close().await,
933            ConversationInner::OpenCode(backend) => backend.close().await,
934            ConversationInner::Custom(backend) => backend.close().await,
935        }
936    }
937}
938
939impl<S, D, P, T> ConversationBackend for Conversation<S, D, P, T>
940where
941    S: StateStoreBackend,
942    D: DatabaseBackend,
943    P: ProviderBackend,
944    T: ToolsetBackend,
945{
946    fn id(&self) -> ConversationId {
947        self.inner.id()
948    }
949
950    async fn info(&self) -> Result<ConversationInfo, KitError> {
951        self.inner.info().await.map_err(|error| {
952            error
953                .at_operation(crate::error::Operation::ConversationInfo)
954                .in_phase(crate::error::ErrorPhase::StateRead)
955        })
956    }
957
958    async fn prompt(&self, request: PromptRequest) -> Result<PromptResponse, KitError> {
959        self.inner.prompt(request).await.map_err(|error| {
960            error
961                .at_operation(crate::error::Operation::Prompt)
962                .in_phase(crate::error::ErrorPhase::ModelGeneration)
963        })
964    }
965
966    async fn prompt_stream(&self, request: PromptRequest) -> Result<PromptStream, KitError> {
967        self.inner.prompt_stream(request).await.map_err(|error| {
968            error
969                .at_operation(crate::error::Operation::Prompt)
970                .in_phase(crate::error::ErrorPhase::ModelGeneration)
971        })
972    }
973
974    async fn abort(&self) -> Result<(), KitError> {
975        self.inner.abort().await.map_err(|error| {
976            error
977                .at_operation(crate::error::Operation::Abort)
978                .in_phase(crate::error::ErrorPhase::Cancellation)
979        })
980    }
981
982    async fn read_file(&self, path: PathBuf) -> Result<FileRead, KitError> {
983        self.inner.read_file(path).await.map_err(|error| {
984            error
985                .at_operation(crate::error::Operation::ReadFile)
986                .in_phase(crate::error::ErrorPhase::Admission)
987        })
988    }
989
990    fn notification_stream(&self) -> Result<ConversationNotificationReceiver, KitError> {
991        self.inner.notification_stream().map_err(|error| {
992            error
993                .at_operation(crate::error::Operation::NotificationStream)
994                .in_phase(crate::error::ErrorPhase::Admission)
995        })
996    }
997
998    async fn resolve_notification(
999        &self,
1000        id: NotificationId,
1001        resolution: NotificationResolution,
1002    ) -> Result<(), KitError> {
1003        self.inner
1004            .resolve_notification(id, resolution)
1005            .await
1006            .map_err(|error| {
1007                error
1008                    .at_operation(crate::error::Operation::ResolveNotification)
1009                    .in_phase(crate::error::ErrorPhase::InteractionResolution)
1010            })
1011    }
1012
1013    async fn close(self) -> Result<(), KitError> {
1014        self.inner.close().await.map_err(|error| {
1015            error
1016                .at_operation(crate::error::Operation::CloseConversation)
1017                .in_phase(crate::error::ErrorPhase::Lifecycle)
1018        })
1019    }
1020}