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/// The shape of a value flowing through a pipeline.
56///
57/// Deliberately small. This exists to catch the mistake that actually happens
58/// when blocks are composed — one block emitting a summary string into another
59/// expecting a list of chunks — not to be a general-purpose type system. A
60/// richer one would need inference, and inference over a language with no
61/// expressions is machinery without a use.
62/// Written and read as a compact string — `text`, `[text]`, `{path: text}` —
63/// rather than as a nested tagged object.
64///
65/// Two reasons, and the second is the one that forced it. It reads well in an
66/// error message and in a spec, so one syntax serves the wire, the diagnostics,
67/// and the DSL. And a recursive enum serialized structurally makes serde's
68/// generic serializer recurse deeply enough to blow rustc's recursion limit in
69/// the *guest* crate — which would have meant every block author adding
70/// `#![recursion_limit]` to work around a detail of this type.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum Ty {
73    /// A UTF-8 string.
74    Text,
75    /// Opaque bytes, base64-encoded on the wire.
76    Bytes,
77    /// A handle naming an image the host holds.
78    Image,
79    /// A handle naming a paged document.
80    Document,
81    /// Any JSON value. The top type: everything is assignable to it.
82    ///
83    /// An escape hatch, and one worth using sparingly — a pipeline of `Json`
84    /// seams typechecks unconditionally, which is the same as not checking.
85    Json,
86    /// An ordered sequence.
87    List(Box<Ty>),
88    /// A fixed set of named fields.
89    ///
90    /// A `BTreeMap` so that two records written in different field orders are
91    /// the same type, and so error messages list fields the same way twice.
92    Record(std::collections::BTreeMap<String, Ty>),
93}
94
95impl Ty {
96    /// Whether a value of this type can be fed where `expected` is required.
97    ///
98    /// Not equality: [`Ty::Json`] accepts anything, and a record with *extra*
99    /// fields satisfies one that needs fewer. Both directions of that matter —
100    /// a block that adds a field should not break its consumer, and a block
101    /// that requires a field its producer never emits should fail loudly.
102    pub fn assignable_to(&self, expected: &Ty) -> bool {
103        match (self, expected) {
104            (_, Ty::Json) => true,
105            (Ty::List(a), Ty::List(b)) => a.assignable_to(b),
106            (Ty::Record(have), Ty::Record(need)) => need
107                .iter()
108                .all(|(name, want)| have.get(name).is_some_and(|got| got.assignable_to(want))),
109            (a, b) => a == b,
110        }
111    }
112
113    /// A short human-readable rendering, for error messages.
114    pub fn describe(&self) -> String {
115        match self {
116            Ty::Text => "text".into(),
117            Ty::Bytes => "bytes".into(),
118            Ty::Image => "image".into(),
119            Ty::Document => "document".into(),
120            Ty::Json => "json".into(),
121            Ty::List(inner) => format!("[{}]", inner.describe()),
122            Ty::Record(fields) => {
123                let body = fields
124                    .iter()
125                    .map(|(k, v)| format!("{k}: {}", v.describe()))
126                    .collect::<Vec<_>>()
127                    .join(", ");
128                format!("{{{body}}}")
129            }
130        }
131    }
132}
133
134impl std::fmt::Display for Ty {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        f.write_str(&self.describe())
137    }
138}
139
140impl std::str::FromStr for Ty {
141    type Err = String;
142
143    fn from_str(s: &str) -> Result<Self, Self::Err> {
144        parse_ty(s.trim())
145    }
146}
147
148impl Serialize for Ty {
149    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
150        s.serialize_str(&self.describe())
151    }
152}
153
154impl<'de> Deserialize<'de> for Ty {
155    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
156        let raw = String::deserialize(d)?;
157        raw.parse().map_err(serde::de::Error::custom)
158    }
159}
160
161/// Parse the type syntax [`Ty::describe`] produces.
162fn parse_ty(s: &str) -> Result<Ty, String> {
163    let s = s.trim();
164    match s {
165        "text" => return Ok(Ty::Text),
166        "bytes" => return Ok(Ty::Bytes),
167        "image" => return Ok(Ty::Image),
168        "document" => return Ok(Ty::Document),
169        "json" => return Ok(Ty::Json),
170        _ => {}
171    }
172
173    if let Some(inner) = s.strip_prefix('[').and_then(|r| r.strip_suffix(']')) {
174        return Ok(Ty::List(Box::new(parse_ty(inner)?)));
175    }
176
177    if let Some(body) = s.strip_prefix('{').and_then(|r| r.strip_suffix('}')) {
178        let mut fields = std::collections::BTreeMap::new();
179        if !body.trim().is_empty() {
180            for part in split_fields(body) {
181                let (name, ty) = part
182                    .split_once(':')
183                    .ok_or_else(|| format!("expected `name: type` in `{part}`"))?;
184                fields.insert(name.trim().to_string(), parse_ty(ty)?);
185            }
186        }
187        return Ok(Ty::Record(fields));
188    }
189
190    Err(format!("`{s}` is not a type"))
191}
192
193/// Split record fields on commas that are not inside a nested `[]` or `{}`.
194///
195/// A plain `split(',')` would cut `{a: [x, y]}` in the wrong place.
196fn split_fields(body: &str) -> Vec<String> {
197    let (mut out, mut depth, mut current) = (Vec::new(), 0i32, String::new());
198    for c in body.chars() {
199        match c {
200            '[' | '{' => {
201                depth += 1;
202                current.push(c);
203            }
204            ']' | '}' => {
205                depth -= 1;
206                current.push(c);
207            }
208            ',' if depth == 0 => out.push(std::mem::take(&mut current)),
209            _ => current.push(c),
210        }
211    }
212    if !current.trim().is_empty() {
213        out.push(current);
214    }
215    out
216}
217
218/// What a block accepts and produces.
219///
220/// Declared by the block itself, through a `cf_signature` export, rather than in
221/// a sidecar file beside it. A sidecar can disagree with the code it describes
222/// and nothing forces anyone to notice; a declaration compiled into the module
223/// travels with it, cannot go stale, and leaves one artifact to ship rather than
224/// two to keep in step.
225///
226/// `Display`/`FromStr` render and parse it as `"{input} -> {output}"` —
227/// each side is a [`Ty`], and this is the compact form the catalog caches
228/// and a bundle manifest embeds. Splitting on `" -> "` is unambiguous only
229/// because `Ty::describe()` never produces that substring itself.
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct Signature {
232    /// What the block needs as input.
233    pub input: Ty,
234    /// What it produces.
235    pub output: Ty,
236}
237
238impl std::fmt::Display for Signature {
239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        write!(f, "{} -> {}", self.input, self.output)
241    }
242}
243
244impl std::str::FromStr for Signature {
245    type Err = String;
246
247    fn from_str(s: &str) -> Result<Self, Self::Err> {
248        let (input, output) = s
249            .split_once(" -> ")
250            .ok_or_else(|| format!("`{s}` is not a signature (expected `input -> output`)"))?;
251        Ok(Signature {
252            input: input.parse()?,
253            output: output.parse()?,
254        })
255    }
256}
257
258/// What kind of thing a handle refers to, reported by [`Event::Opened`].
259///
260/// A block needs this to know which commands are worth issuing: [`Command::Slice`]
261/// on a PNG is a mistake, and [`Command::PageText`] on a plain text file is
262/// meaningless. Reporting it up front means a block can branch on what it
263/// actually got rather than guessing from a file extension.
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(tag = "kind", rename_all = "snake_case")]
266pub enum MediaKind {
267    /// Valid UTF-8. Both [`Command::Slice`] and [`Command::SliceBytes`] work.
268    Text,
269    /// An image the host recognised. Usable as an [`Command::Infer`] image.
270    Image {
271        /// Format as detected from content, e.g. `png`, `jpeg`.
272        format: String,
273    },
274    /// A paged document — a PDF, say.
275    Document {
276        /// How many pages it has.
277        pages: u32,
278        /// Whether it carries an extractable text layer.
279        ///
280        /// False for a scanned document, where the only way to read it is to
281        /// rasterize pages and hand them to a vision model. A block that checks
282        /// this can pick the cheap path when it exists and the expensive one
283        /// when it must, instead of silently extracting nothing.
284        has_text_layer: bool,
285    },
286    /// Bytes the host could not classify. Only [`Command::SliceBytes`] applies.
287    Binary,
288}
289
290/// What a guest asks the host to do, returned from its `init`/`step` exports.
291#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
292#[serde(tag = "cmd", rename_all = "snake_case")]
293pub enum Command {
294    /// Run a prompt against the job's model.
295    Infer {
296        /// The prompt to generate from.
297        prompt: String,
298        /// Upper bound on tokens generated. A guest can also end generation
299        /// early by returning [`TokenAction::Stop`] from its `on_token` export.
300        max_tokens: u32,
301        /// Images to accompany the prompt, named by handle.
302        ///
303        /// Handles rather than bytes, for the same reason file contents are not
304        /// handed over: an image can be tens of megabytes, and routing it
305        /// through guest memory would put the 4 GiB wasm32 ceiling back in play
306        /// for no benefit. The host already holds the bytes; it can pass them to
307        /// the model directly.
308        ///
309        /// Requires a model with vision capability. Empty for ordinary
310        /// text-only inference, which is why it is `#[serde(default)]` — a block
311        /// compiled before this field existed still deserializes.
312        #[serde(default)]
313        images: Vec<Handle>,
314    },
315    /// Open a file. Capability-checked against the job's spec.
316    ///
317    /// Yields a handle and a length rather than contents — see the crate docs on
318    /// why bulk data does not cross this boundary.
319    Open {
320        /// Path to open. Denied unless the spec grants read access to it.
321        path: String,
322    },
323    /// Pull one bounded window of an open file into guest memory.
324    ///
325    /// The guest picks `len`, so the guest sets its own memory ceiling.
326    Slice {
327        /// Handle from a previous [`Command::Open`].
328        handle: Handle,
329        /// Byte offset to read from. `u64` so that files far larger than a guest
330        /// could hold remain fully addressable.
331        offset: u64,
332        /// Maximum bytes to return. The host may return fewer; see
333        /// [`Event::Sliced`].
334        len: u64,
335    },
336    /// Pull one bounded window of an open file as raw bytes.
337    ///
338    /// The binary counterpart to [`Command::Slice`]. Prefer `Slice` for text:
339    /// it needs no encoding, and it handles the character-boundary problem for
340    /// you. This exists for blocks that genuinely need bytes — inspecting an
341    /// image header, say — and pays base64's cost to carry them.
342    SliceBytes {
343        /// Handle from a previous [`Command::Open`].
344        handle: Handle,
345        /// Byte offset to read from.
346        offset: u64,
347        /// Maximum bytes to return.
348        len: u64,
349    },
350    /// Extract one page of a document as text.
351    ///
352    /// Fails when the document has no text layer; check
353    /// [`MediaKind::Document::has_text_layer`] first.
354    PageText {
355        /// Handle from a previous [`Command::Open`].
356        handle: Handle,
357        /// Zero-based page number.
358        page: u32,
359    },
360    /// Render one page of a document to an image.
361    ///
362    /// Yields a *new* handle referring to the rendered image, which can then be
363    /// named in [`Command::Infer`]. That indirection is deliberate: the image
364    /// stays host-side like every other bulk value, and a rendered page is
365    /// usable exactly wherever a file-backed image is.
366    PageImage {
367        /// Handle from a previous [`Command::Open`].
368        handle: Handle,
369        /// Zero-based page number.
370        page: u32,
371    },
372    /// Report progress to whoever is watching the job's event stream.
373    Emit {
374        /// Arbitrary JSON, forwarded verbatim to the job's subscribers.
375        progress: serde_json::Value,
376    },
377    /// Finish successfully with this payload.
378    Done {
379        /// The job's result, shaped by the spec's declared output.
380        result: serde_json::Value,
381    },
382    /// Give up. The job ends with this code and message, and no result.
383    Fail {
384        /// Machine-readable code; see [`error_codes`].
385        code: String,
386        /// Human-readable explanation.
387        message: String,
388    },
389}
390
391/// What the host feeds back into the guest's `step` export after carrying out a
392/// [`Command`].
393#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
394#[serde(tag = "event", rename_all = "snake_case")]
395pub enum Event {
396    /// Generation finished.
397    InferDone {
398        /// The generated text.
399        text: String,
400        /// How many tokens were produced. May be fewer than the requested
401        /// `max_tokens` if the guest ended generation early.
402        tokens_out: u32,
403    },
404    /// A file was opened.
405    Opened {
406        /// Use this in subsequent [`Command::Slice`] calls.
407        handle: Handle,
408        /// Total size of the file, in bytes.
409        len: u64,
410        /// What the host made of the contents; see [`MediaKind`].
411        ///
412        /// `#[serde(default)]` so a block built before this field existed still
413        /// deserializes, treating anything it opens as text.
414        #[serde(default)]
415        kind: MediaKind,
416    },
417    /// A window of a file was read.
418    Sliced {
419        /// The window's contents.
420        text: String,
421        /// Where the returned text actually ended.
422        ///
423        /// This is **not** always `offset + len` from the request: the host cuts
424        /// a window back to a UTF-8 character boundary, because a caller picking
425        /// window sizes has no idea where characters begin, and a naive split
426        /// would corrupt a multi-byte character at nearly every seam. A guest
427        /// walking a file must resume from this value rather than advancing by
428        /// the length it asked for.
429        next_offset: u64,
430    },
431    /// A window of a file was read as raw bytes.
432    SlicedBytes {
433        /// The window's contents, base64-encoded.
434        ///
435        /// Base64 rather than a binary side channel: the boundary is JSON, and
436        /// keeping it inspectable is worth more than the third it costs on a
437        /// path blocks are not expected to use in bulk.
438        bytes_base64: String,
439        /// Where the returned bytes ended. Unlike [`Event::Sliced`] there is no
440        /// truncation, so this is always `offset + len` clamped to the file.
441        next_offset: u64,
442    },
443    /// A document page was extracted as text.
444    PageTexted {
445        /// The page's text.
446        text: String,
447    },
448    /// A document page was rendered to an image.
449    PageImaged {
450        /// A new handle referring to the rendered image; name it in
451        /// [`Command::Infer`].
452        handle: Handle,
453        /// Its size in bytes.
454        len: u64,
455    },
456    /// Progress was forwarded. Carries nothing; it exists so `Emit` has a reply
457    /// and the command loop keeps its shape.
458    Emitted,
459}
460
461/// A guest's verdict on each streamed token, returned from its `on_token`
462/// export.
463#[derive(Debug, Clone, Copy, PartialEq, Eq)]
464pub enum TokenAction {
465    /// Keep generating.
466    Continue,
467    /// Stop generating now.
468    ///
469    /// A token or two may still arrive after this, because the verdict has to
470    /// travel back to the thread doing the generating.
471    Stop,
472}
473
474impl TokenAction {
475    /// Decode the raw `i32` a guest's `on_token` export returns.
476    ///
477    /// Anything that is not an explicit `Continue` reads as `Stop`. A guest
478    /// returning a value this crate does not recognise is malfunctioning, and
479    /// the safe reading of a malfunctioning guest is "stop", never "keep
480    /// spending tokens" — the same fail-closed posture as the capability checks.
481    pub fn from_i32(v: i32) -> Self {
482        if v == 0 {
483            Self::Continue
484        } else {
485            Self::Stop
486        }
487    }
488
489    /// Encode for the wasm boundary.
490    ///
491    /// These integers are part of the ABI: renumbering them silently changes the
492    /// meaning of every already-compiled block.
493    pub fn as_i32(self) -> i32 {
494        match self {
495            Self::Continue => 0,
496            Self::Stop => 1,
497        }
498    }
499}
500
501/// Where a job is in its lifecycle.
502#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
503#[serde(rename_all = "snake_case")]
504pub enum JobStatus {
505    /// Accepted, not yet started.
506    Queued,
507    /// Executing.
508    Running,
509    /// Finished with a result.
510    Completed,
511    /// Finished with an error and no result.
512    Failed,
513    /// Stopped by request.
514    Cancelled,
515    /// Was running when the daemon last stopped; not resumed automatically.
516    Interrupted,
517}
518
519impl JobStatus {
520    /// Whether this status is final — nothing further will happen to the job.
521    ///
522    /// Clients poll until this is true. Adding a new non-terminal status is
523    /// therefore safe, while a new terminal one that is missing from this match
524    /// leaves callers waiting forever.
525    pub fn is_terminal(self) -> bool {
526        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
527    }
528}
529
530/// What a job cost.
531#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
532pub struct Usage {
533    /// Tokens consumed by prompts.
534    pub tokens_in: u32,
535    /// Tokens generated.
536    pub tokens_out: u32,
537    /// Wall-clock duration of the job.
538    pub duration_ms: u64,
539    /// Which model served the job's inference.
540    pub model: String,
541}
542
543/// The fixed, spec-independent envelope handed back to the calling agent.
544///
545/// Every job returns this shape regardless of what it did, so an agent can
546/// handle results without knowing anything about the block that produced them.
547/// Only `result` varies, and its shape is that job's business.
548#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
549pub struct Envelope {
550    /// Lifecycle state; see [`JobStatus::is_terminal`].
551    pub status: JobStatus,
552    /// Present only when the job completed.
553    ///
554    /// A failed or cancelled job never carries a partial result: a caller must
555    /// never have to guess whether a payload is trustworthy.
556    #[serde(skip_serializing_if = "Option::is_none")]
557    pub result: Option<serde_json::Value>,
558    /// Present only when the job failed or was cancelled.
559    #[serde(skip_serializing_if = "Option::is_none")]
560    pub error: Option<JobError>,
561    /// Cost accounting, populated even for failed jobs — work already spent
562    /// still counts.
563    pub usage: Usage,
564}
565
566/// Why a job did not complete.
567#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
568pub struct JobError {
569    /// Machine-readable; see [`error_codes`].
570    pub code: String,
571    /// Human-readable detail.
572    pub message: String,
573}
574
575/// The error codes the daemon emits in [`JobError::code`].
576///
577/// These are string constants rather than an enum so the set can grow without
578/// breaking clients that match on strings, and so a client built against an
579/// older version meets an unfamiliar code rather than a decode failure.
580pub mod error_codes {
581    /// The job's model could not be loaded or served.
582    pub const MODEL_LOAD_FAILED: &str = "model_load_failed";
583    /// A guest tried to reach something its spec does not grant.
584    pub const CAPABILITY_DENIED: &str = "capability_denied";
585    /// Job input did not match the spec's declared shape.
586    pub const SCHEMA_VALIDATION_FAILED: &str = "schema_validation_failed";
587    /// The guest trapped — a panic, a bad export signature, or malformed wasm.
588    pub const WASM_TRAP: &str = "wasm_trap";
589    /// The job exceeded its time budget.
590    pub const TIMEOUT: &str = "timeout";
591    /// The job was cancelled by request.
592    pub const CANCELLED: &str = "cancelled";
593    /// A command needed a capability this build does not have — asking for a
594    /// page image without document rendering compiled in, say.
595    pub const UNSUPPORTED: &str = "unsupported";
596}
597
598impl Default for MediaKind {
599    /// Text, because that is what every command predating [`MediaKind`]
600    /// assumed, and because it keeps an older block's behaviour unchanged.
601    fn default() -> Self {
602        Self::Text
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    #[test]
611    fn a_signature_round_trips_through_its_compact_string() {
612        let sig = Signature {
613            input: Ty::Record([("path".to_string(), Ty::Text)].into_iter().collect()),
614            output: Ty::List(Box::new(Ty::Text)),
615        };
616        let s = sig.to_string();
617        assert_eq!(s, "{path: text} -> [text]");
618        assert_eq!(s.parse::<Signature>().unwrap(), sig);
619    }
620
621    #[test]
622    fn a_signature_without_an_arrow_is_rejected() {
623        assert!("just-a-type".parse::<Signature>().is_err());
624    }
625
626    #[test]
627    fn a_signature_with_an_unparseable_side_is_rejected() {
628        assert!("text -> not a type".parse::<Signature>().is_err());
629    }
630}