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}