Skip to main content

cuttlefish_abi/
lib.rs

1//! The contract between the cuttlefish host and its guest proc-blocks.
2//!
3//! Both sides depend on this crate precisely so that they cannot drift: a block
4//! is compiled separately from the host, often at a different time by a
5//! different person, and the only thing keeping them able to talk is that they
6//! agreed on these types.
7//!
8//! # Why a command loop, not function calls
9//!
10//! A block does not call the host. It *returns* a [`Command`] describing what it
11//! wants done, and the host — after doing it — hands back an [`Event`] and asks
12//! for the next command. Control is inverted relative to the obvious design, and
13//! not for taste:
14//!
15//! - A core-wasm guest is single-threaded and offers no execution context the
16//!   host could call back into while the guest is blocked. A "call the host and
17//!   wait" design has nowhere to deliver the answer.
18//! - Inference must run on a different thread from the wasm store, which is
19//!   `!Sync` and cannot be touched from there.
20//! - Because the host decides whether to take the next step, cancellation needs
21//!   no cooperation from the guest at all: the host simply stops stepping. A
22//!   guest cannot ignore, delay, or trap its way out of being cancelled.
23//!
24//! Everything crosses the boundary as JSON. That is slower than a packed binary
25//! layout, deliberately: the boundary stays inspectable, a mismatch produces a
26//! legible error rather than a misread integer, and the volume is low because
27//! bulk data does not cross it. Revisit only if profiling says to.
28//!
29//! # Why bulk data does not cross this boundary
30//!
31//! No command hands a block the contents of a file. A block [`Command::Open`]s a
32//! path, receives a [`Handle`] and a length, then pulls bounded windows with
33//! [`Command::Slice`].
34//!
35//! This keeps guest memory proportional to the window a block chooses rather
36//! than to the size of its input. A block written against a small file behaves
37//! identically against a huge one, and the 4 GiB ceiling of 32-bit wasm stops
38//! being something block authors must reason about — which is what lets this
39//! project stay on `wasm32` instead of paying for `wasm64`.
40
41#![forbid(unsafe_code)]
42#![warn(missing_docs)]
43
44use serde::{Deserialize, Serialize};
45
46/// A job-scoped reference to something the host holds open for a guest.
47///
48/// Job-scoping is a security property, not bookkeeping. A handle table lives and
49/// dies with a single job, so a handle from one job names nothing in another.
50/// That is why [`Command::Slice`] carries no path and needs no capability check
51/// of its own: the check happened once, at [`Command::Open`], and a handle
52/// cannot be forged into a reference to another job's data.
53pub type Handle = u32;
54
55/// What kind of thing a handle refers to, reported by [`Event::Opened`].
56///
57/// A block needs this to know which commands are worth issuing: [`Command::Slice`]
58/// on a PNG is a mistake, and [`Command::PageText`] on a plain text file is
59/// meaningless. Reporting it up front means a block can branch on what it
60/// actually got rather than guessing from a file extension.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(tag = "kind", rename_all = "snake_case")]
63pub enum MediaKind {
64    /// Valid UTF-8. Both [`Command::Slice`] and [`Command::SliceBytes`] work.
65    Text,
66    /// An image the host recognised. Usable as an [`Command::Infer`] image.
67    Image {
68        /// Format as detected from content, e.g. `png`, `jpeg`.
69        format: String,
70    },
71    /// A paged document — a PDF, say.
72    Document {
73        /// How many pages it has.
74        pages: u32,
75        /// Whether it carries an extractable text layer.
76        ///
77        /// False for a scanned document, where the only way to read it is to
78        /// rasterize pages and hand them to a vision model. A block that checks
79        /// this can pick the cheap path when it exists and the expensive one
80        /// when it must, instead of silently extracting nothing.
81        has_text_layer: bool,
82    },
83    /// Bytes the host could not classify. Only [`Command::SliceBytes`] applies.
84    Binary,
85}
86
87/// What a guest asks the host to do, returned from its `init`/`step` exports.
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
89#[serde(tag = "cmd", rename_all = "snake_case")]
90pub enum Command {
91    /// Run a prompt against the job's model.
92    Infer {
93        /// The prompt to generate from.
94        prompt: String,
95        /// Upper bound on tokens generated. A guest can also end generation
96        /// early by returning [`TokenAction::Stop`] from its `on_token` export.
97        max_tokens: u32,
98        /// Images to accompany the prompt, named by handle.
99        ///
100        /// Handles rather than bytes, for the same reason file contents are not
101        /// handed over: an image can be tens of megabytes, and routing it
102        /// through guest memory would put the 4 GiB wasm32 ceiling back in play
103        /// for no benefit. The host already holds the bytes; it can pass them to
104        /// the model directly.
105        ///
106        /// Requires a model with vision capability. Empty for ordinary
107        /// text-only inference, which is why it is `#[serde(default)]` — a block
108        /// compiled before this field existed still deserializes.
109        #[serde(default)]
110        images: Vec<Handle>,
111    },
112    /// Open a file. Capability-checked against the job's spec.
113    ///
114    /// Yields a handle and a length rather than contents — see the crate docs on
115    /// why bulk data does not cross this boundary.
116    Open {
117        /// Path to open. Denied unless the spec grants read access to it.
118        path: String,
119    },
120    /// Pull one bounded window of an open file into guest memory.
121    ///
122    /// The guest picks `len`, so the guest sets its own memory ceiling.
123    Slice {
124        /// Handle from a previous [`Command::Open`].
125        handle: Handle,
126        /// Byte offset to read from. `u64` so that files far larger than a guest
127        /// could hold remain fully addressable.
128        offset: u64,
129        /// Maximum bytes to return. The host may return fewer; see
130        /// [`Event::Sliced`].
131        len: u64,
132    },
133    /// Pull one bounded window of an open file as raw bytes.
134    ///
135    /// The binary counterpart to [`Command::Slice`]. Prefer `Slice` for text:
136    /// it needs no encoding, and it handles the character-boundary problem for
137    /// you. This exists for blocks that genuinely need bytes — inspecting an
138    /// image header, say — and pays base64's cost to carry them.
139    SliceBytes {
140        /// Handle from a previous [`Command::Open`].
141        handle: Handle,
142        /// Byte offset to read from.
143        offset: u64,
144        /// Maximum bytes to return.
145        len: u64,
146    },
147    /// Extract one page of a document as text.
148    ///
149    /// Fails when the document has no text layer; check
150    /// [`MediaKind::Document::has_text_layer`] first.
151    PageText {
152        /// Handle from a previous [`Command::Open`].
153        handle: Handle,
154        /// Zero-based page number.
155        page: u32,
156    },
157    /// Render one page of a document to an image.
158    ///
159    /// Yields a *new* handle referring to the rendered image, which can then be
160    /// named in [`Command::Infer`]. That indirection is deliberate: the image
161    /// stays host-side like every other bulk value, and a rendered page is
162    /// usable exactly wherever a file-backed image is.
163    PageImage {
164        /// Handle from a previous [`Command::Open`].
165        handle: Handle,
166        /// Zero-based page number.
167        page: u32,
168    },
169    /// Report progress to whoever is watching the job's event stream.
170    Emit {
171        /// Arbitrary JSON, forwarded verbatim to the job's subscribers.
172        progress: serde_json::Value,
173    },
174    /// Finish successfully with this payload.
175    Done {
176        /// The job's result, shaped by the spec's declared output.
177        result: serde_json::Value,
178    },
179    /// Give up. The job ends with this code and message, and no result.
180    Fail {
181        /// Machine-readable code; see [`error_codes`].
182        code: String,
183        /// Human-readable explanation.
184        message: String,
185    },
186}
187
188/// What the host feeds back into the guest's `step` export after carrying out a
189/// [`Command`].
190#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
191#[serde(tag = "event", rename_all = "snake_case")]
192pub enum Event {
193    /// Generation finished.
194    InferDone {
195        /// The generated text.
196        text: String,
197        /// How many tokens were produced. May be fewer than the requested
198        /// `max_tokens` if the guest ended generation early.
199        tokens_out: u32,
200    },
201    /// A file was opened.
202    Opened {
203        /// Use this in subsequent [`Command::Slice`] calls.
204        handle: Handle,
205        /// Total size of the file, in bytes.
206        len: u64,
207        /// What the host made of the contents; see [`MediaKind`].
208        ///
209        /// `#[serde(default)]` so a block built before this field existed still
210        /// deserializes, treating anything it opens as text.
211        #[serde(default)]
212        kind: MediaKind,
213    },
214    /// A window of a file was read.
215    Sliced {
216        /// The window's contents.
217        text: String,
218        /// Where the returned text actually ended.
219        ///
220        /// This is **not** always `offset + len` from the request: the host cuts
221        /// a window back to a UTF-8 character boundary, because a caller picking
222        /// window sizes has no idea where characters begin, and a naive split
223        /// would corrupt a multi-byte character at nearly every seam. A guest
224        /// walking a file must resume from this value rather than advancing by
225        /// the length it asked for.
226        next_offset: u64,
227    },
228    /// A window of a file was read as raw bytes.
229    SlicedBytes {
230        /// The window's contents, base64-encoded.
231        ///
232        /// Base64 rather than a binary side channel: the boundary is JSON, and
233        /// keeping it inspectable is worth more than the third it costs on a
234        /// path blocks are not expected to use in bulk.
235        bytes_base64: String,
236        /// Where the returned bytes ended. Unlike [`Event::Sliced`] there is no
237        /// truncation, so this is always `offset + len` clamped to the file.
238        next_offset: u64,
239    },
240    /// A document page was extracted as text.
241    PageTexted {
242        /// The page's text.
243        text: String,
244    },
245    /// A document page was rendered to an image.
246    PageImaged {
247        /// A new handle referring to the rendered image; name it in
248        /// [`Command::Infer`].
249        handle: Handle,
250        /// Its size in bytes.
251        len: u64,
252    },
253    /// Progress was forwarded. Carries nothing; it exists so `Emit` has a reply
254    /// and the command loop keeps its shape.
255    Emitted,
256}
257
258/// A guest's verdict on each streamed token, returned from its `on_token`
259/// export.
260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261pub enum TokenAction {
262    /// Keep generating.
263    Continue,
264    /// Stop generating now.
265    ///
266    /// A token or two may still arrive after this, because the verdict has to
267    /// travel back to the thread doing the generating.
268    Stop,
269}
270
271impl TokenAction {
272    /// Decode the raw `i32` a guest's `on_token` export returns.
273    ///
274    /// Anything that is not an explicit `Continue` reads as `Stop`. A guest
275    /// returning a value this crate does not recognise is malfunctioning, and
276    /// the safe reading of a malfunctioning guest is "stop", never "keep
277    /// spending tokens" — the same fail-closed posture as the capability checks.
278    pub fn from_i32(v: i32) -> Self {
279        if v == 0 {
280            Self::Continue
281        } else {
282            Self::Stop
283        }
284    }
285
286    /// Encode for the wasm boundary.
287    ///
288    /// These integers are part of the ABI: renumbering them silently changes the
289    /// meaning of every already-compiled block.
290    pub fn as_i32(self) -> i32 {
291        match self {
292            Self::Continue => 0,
293            Self::Stop => 1,
294        }
295    }
296}
297
298/// Where a job is in its lifecycle.
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
300#[serde(rename_all = "snake_case")]
301pub enum JobStatus {
302    /// Accepted, not yet started.
303    Queued,
304    /// Executing.
305    Running,
306    /// Finished with a result.
307    Completed,
308    /// Finished with an error and no result.
309    Failed,
310    /// Stopped by request.
311    Cancelled,
312}
313
314impl JobStatus {
315    /// Whether this status is final — nothing further will happen to the job.
316    ///
317    /// Clients poll until this is true. Adding a new non-terminal status is
318    /// therefore safe, while a new terminal one that is missing from this match
319    /// leaves callers waiting forever.
320    pub fn is_terminal(self) -> bool {
321        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
322    }
323}
324
325/// What a job cost.
326#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
327pub struct Usage {
328    /// Tokens consumed by prompts.
329    pub tokens_in: u32,
330    /// Tokens generated.
331    pub tokens_out: u32,
332    /// Wall-clock duration of the job.
333    pub duration_ms: u64,
334    /// Which model served the job's inference.
335    pub model: String,
336}
337
338/// The fixed, spec-independent envelope handed back to the calling agent.
339///
340/// Every job returns this shape regardless of what it did, so an agent can
341/// handle results without knowing anything about the block that produced them.
342/// Only `result` varies, and its shape is that job's business.
343#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
344pub struct Envelope {
345    /// Lifecycle state; see [`JobStatus::is_terminal`].
346    pub status: JobStatus,
347    /// Present only when the job completed.
348    ///
349    /// A failed or cancelled job never carries a partial result: a caller must
350    /// never have to guess whether a payload is trustworthy.
351    #[serde(skip_serializing_if = "Option::is_none")]
352    pub result: Option<serde_json::Value>,
353    /// Present only when the job failed or was cancelled.
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub error: Option<JobError>,
356    /// Cost accounting, populated even for failed jobs — work already spent
357    /// still counts.
358    pub usage: Usage,
359}
360
361/// Why a job did not complete.
362#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
363pub struct JobError {
364    /// Machine-readable; see [`error_codes`].
365    pub code: String,
366    /// Human-readable detail.
367    pub message: String,
368}
369
370/// The error codes the daemon emits in [`JobError::code`].
371///
372/// These are string constants rather than an enum so the set can grow without
373/// breaking clients that match on strings, and so a client built against an
374/// older version meets an unfamiliar code rather than a decode failure.
375pub mod error_codes {
376    /// The job's model could not be loaded or served.
377    pub const MODEL_LOAD_FAILED: &str = "model_load_failed";
378    /// A guest tried to reach something its spec does not grant.
379    pub const CAPABILITY_DENIED: &str = "capability_denied";
380    /// Job input did not match the spec's declared shape.
381    pub const SCHEMA_VALIDATION_FAILED: &str = "schema_validation_failed";
382    /// The guest trapped — a panic, a bad export signature, or malformed wasm.
383    pub const WASM_TRAP: &str = "wasm_trap";
384    /// The job exceeded its time budget.
385    pub const TIMEOUT: &str = "timeout";
386    /// The job was cancelled by request.
387    pub const CANCELLED: &str = "cancelled";
388    /// A command needed a capability this build does not have — asking for a
389    /// page image without document rendering compiled in, say.
390    pub const UNSUPPORTED: &str = "unsupported";
391}
392
393impl Default for MediaKind {
394    /// Text, because that is what every command predating [`MediaKind`]
395    /// assumed, and because it keeps an older block's behaviour unchanged.
396    fn default() -> Self {
397        Self::Text
398    }
399}