Skip to main content

ai_ui_core/
runtime.rs

1//! The thread runtime interface, after upstream's `ThreadRuntimeCore`.
2//!
3//! UIs and the state layer bind to [`ThreadRuntime`], so they work with every
4//! runtime: [`LocalThreadRuntime`](crate::LocalThreadRuntime) (this library
5//! drives the model), [`ExternalStoreRuntime`](crate::ExternalStoreRuntime)
6//! (the app owns the messages) and the threads managed by a
7//! [`ThreadList`](crate::ThreadList).
8
9use crate::adapter::RunError;
10use crate::repository::{ExportedRepository, MessageChanges, MessageEntry, RepositoryError};
11use crate::subscribe::Subscription;
12use crate::types::{AppendMessage, MessageId, RunConfig, SubmittedFeedback, ThreadMessage};
13use crate::Composer;
14use serde_json::Value;
15use std::fmt;
16use std::future::Future;
17use std::pin::Pin;
18use std::sync::Arc;
19
20/// A model run, or an app callback, to spawn. It resolves when the work ends:
21/// `Ok` for completion or cancellation, `Err` when it failed (the message
22/// records a model failure too).
23pub type RunFuture = Pin<Box<dyn Future<Output = Result<(), RunError>>>>;
24
25/// `startRun` and reload configuration (`StartRunConfig`).
26#[derive(Clone, Debug, Default, PartialEq)]
27pub struct StartRunConfig {
28    /// The message to answer; `None` answers an empty thread.
29    pub parent_id: Option<MessageId>,
30    /// The assistant message being regenerated, if any.
31    pub source_id: Option<MessageId>,
32    /// Configuration for the run.
33    pub run_config: RunConfig,
34}
35
36/// `addToolResult` arguments.
37#[derive(Clone, Debug, PartialEq)]
38pub struct ToolResult {
39    /// The assistant message holding the call.
40    pub message_id: MessageId,
41    /// Which call the result answers.
42    pub tool_call_id: String,
43    /// The result value.
44    pub result: Value,
45    /// The result is an error.
46    pub is_error: bool,
47    /// Extra output for the app, not sent to the model.
48    pub artifact: Option<Value>,
49}
50
51/// What a runtime can do (`RuntimeCapabilities`); UIs hide the matching
52/// actions when a capability is missing.
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub struct RuntimeCapabilities {
55    /// The thread can switch between branches.
56    pub switch_to_branch: bool,
57    /// User messages can be edited.
58    pub edit: bool,
59    /// An answer can be regenerated.
60    pub reload: bool,
61    /// A run can be stopped.
62    pub cancel: bool,
63    /// Message text can be copied.
64    pub copy: bool,
65    /// Feedback can be submitted.
66    pub feedback: bool,
67    /// Tool calls can be answered from the UI.
68    pub answer_tool_call: bool,
69}
70
71impl RuntimeCapabilities {
72    /// Every capability on.
73    pub const ALL: Self = Self {
74        switch_to_branch: true,
75        edit: true,
76        reload: true,
77        cancel: true,
78        copy: true,
79        feedback: true,
80        answer_tool_call: true,
81    };
82}
83
84/// Why a runtime operation failed.
85#[derive(Clone, Debug, PartialEq, Eq)]
86#[non_exhaustive]
87pub enum RuntimeError {
88    /// The message tree rejected the operation.
89    Repository(RepositoryError),
90    /// The operation applies to assistant messages only.
91    NotAssistantMessage(MessageId),
92    /// The message has no tool call with this id.
93    ToolCallNotFound {
94        /// The message that was searched.
95        message_id: MessageId,
96        /// The call id that was not found.
97        tool_call_id: String,
98    },
99    /// The operation would rewrite history a running model is reading.
100    RunInProgress,
101    /// `begin_edit` for a message that already has an edit composer.
102    EditInProgress(MessageId),
103    /// No pending approval with this id on that message.
104    ApprovalNotFound(String),
105    /// This runtime does not support the operation.
106    Unsupported(&'static str),
107    /// The runtime was dropped.
108    Disposed,
109}
110
111impl fmt::Display for RuntimeError {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        match self {
114            Self::Repository(error) => error.fmt(f),
115            Self::NotAssistantMessage(id) => write!(f, "message {id} is not an assistant message"),
116            Self::ToolCallNotFound {
117                message_id,
118                tool_call_id,
119            } => {
120                write!(f, "message {message_id} has no tool call {tool_call_id}")
121            }
122            Self::RunInProgress => f.write_str("a run is in progress"),
123            Self::EditInProgress(id) => write!(f, "message {id} is already being edited"),
124            Self::ApprovalNotFound(id) => write!(f, "no pending approval {id}"),
125            Self::Unsupported(what) => write!(f, "this runtime does not support {what}"),
126            Self::Disposed => f.write_str("the runtime was dropped"),
127        }
128    }
129}
130impl std::error::Error for RuntimeError {}
131impl From<RepositoryError> for RuntimeError {
132    fn from(error: RepositoryError) -> Self {
133        Self::Repository(error)
134    }
135}
136
137/// One thread: its messages, branches, composers and actions.
138///
139/// Methods change state synchronously and notify subscribers. A method that
140/// needs asynchronous work returns a [`RunFuture`] for the host to spawn.
141pub trait ThreadRuntime {
142    /// The selected branch, root first.
143    fn messages(&self) -> Arc<[Arc<ThreadMessage>]>;
144    /// A message and its position, by id.
145    fn message(&self, id: &MessageId) -> Result<MessageEntry, RuntimeError>;
146    /// Whether a run is in progress.
147    fn is_running(&self) -> bool;
148    /// What the UI may offer for this thread.
149    fn capabilities(&self) -> RuntimeCapabilities;
150    /// The message and its siblings.
151    fn branches(&self, id: &MessageId) -> Result<Vec<MessageId>, RuntimeError>;
152    /// `(position, count)` of the message among its siblings, 1-based. The
153    /// default builds [`branches`](Self::branches); runtimes override it to
154    /// avoid the allocation, which a UI pays once per message per update.
155    fn branch_position(&self, id: &MessageId) -> Result<(usize, usize), RuntimeError> {
156        let branches = self.branches(id)?;
157        let position = branches.iter().position(|b| b == id).map_or(1, |p| p + 1);
158        Ok((position, branches.len().max(1)))
159    }
160    /// A number that changes whenever branches or the selected path change,
161    /// but not when a message is updated in place. While it is unchanged a UI
162    /// may keep every message's [`branch_position`](Self::branch_position).
163    /// `None` (the default) means unknown: positions must be read again.
164    fn structure_version(&self) -> Option<u64> {
165        None
166    }
167    /// Changed selected-message indices since a consumer's last version.
168    /// `None` (the default) means this runtime does not track changes: scan
169    /// [`messages`](Self::messages). A supported runtime returns a current
170    /// version even when the journal requires a full read. Changes must cover
171    /// every selected message update and be valid for the current `messages()`
172    /// snapshot; versions are local to this runtime.
173    /// Consumers must still refresh when running state or capabilities change.
174    fn message_changes_since(&self, _version: Option<u64>) -> Option<MessageChanges> {
175        None
176    }
177    /// Select the branch containing `id`.
178    fn switch_to_branch(&self, id: &MessageId) -> Result<(), RuntimeError>;
179    /// Add a message; a user message answers it (see [`AppendMessage`]).
180    fn append(&self, message: AppendMessage) -> Result<RunFuture, RuntimeError>;
181    /// Answer `parent_id` again (regenerate).
182    fn reload(&self, config: StartRunConfig) -> Result<RunFuture, RuntimeError>;
183    /// Stop the run in progress, if any.
184    fn cancel_run(&self);
185    /// Answer a tool call; may resume the run, which is then returned to spawn.
186    fn add_tool_result(&self, result: ToolResult) -> Result<Option<RunFuture>, RuntimeError>;
187    /// Approve or deny a pending tool approval; may resume the run, which is then returned to spawn.
188    fn respond_to_tool_approval(
189        &self,
190        message_id: &MessageId,
191        approval_id: &str,
192        approved: bool,
193        reason: Option<String>,
194    ) -> Result<Option<RunFuture>, RuntimeError>;
195    /// `None` clears feedback.
196    fn submit_feedback(
197        &self,
198        id: &MessageId,
199        feedback: Option<SubmittedFeedback>,
200    ) -> Result<(), RuntimeError>;
201    /// The composer for the next message.
202    fn composer(&self) -> Composer;
203    /// Start editing a message and return its composer.
204    fn begin_edit(&self, id: &MessageId) -> Result<Composer, RuntimeError>;
205    /// The composer of a message being edited, if any.
206    fn edit_composer(&self, id: &MessageId) -> Option<Composer>;
207    /// Called after every change; drop the guard to unsubscribe.
208    fn subscribe_dyn(&self, callback: Box<dyn Fn()>) -> Subscription;
209    /// Persisted messages and the selected head.
210    fn export(&self) -> ExportedRepository;
211    /// Load history.
212    fn import(&self, exported: ExportedRepository) -> Result<(), RuntimeError>;
213}
214
215/// What a composer needs from its runtime.
216pub trait ComposerHost {
217    /// The newest message on the selected path, which a new message answers.
218    fn last_message_id(&self) -> Option<MessageId>;
219    /// Send a message.
220    fn append(&self, message: AppendMessage) -> Result<RunFuture, RuntimeError>;
221    /// Stop the run in progress, if any.
222    fn cancel_run(&self);
223    /// Whether a run is in progress.
224    fn is_running(&self) -> bool;
225    /// An edit composer finished (sent or cancelled).
226    fn end_edit(&self, id: &MessageId);
227}