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 a guest asks the host to do, returned from its `init`/`step` exports.
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57#[serde(tag = "cmd", rename_all = "snake_case")]
58pub enum Command {
59    /// Run a prompt against the job's model.
60    Infer {
61        /// The prompt to generate from.
62        prompt: String,
63        /// Upper bound on tokens generated. A guest can also end generation
64        /// early by returning [`TokenAction::Stop`] from its `on_token` export.
65        max_tokens: u32,
66    },
67    /// Open a file. Capability-checked against the job's spec.
68    ///
69    /// Yields a handle and a length rather than contents — see the crate docs on
70    /// why bulk data does not cross this boundary.
71    Open {
72        /// Path to open. Denied unless the spec grants read access to it.
73        path: String,
74    },
75    /// Pull one bounded window of an open file into guest memory.
76    ///
77    /// The guest picks `len`, so the guest sets its own memory ceiling.
78    Slice {
79        /// Handle from a previous [`Command::Open`].
80        handle: Handle,
81        /// Byte offset to read from. `u64` so that files far larger than a guest
82        /// could hold remain fully addressable.
83        offset: u64,
84        /// Maximum bytes to return. The host may return fewer; see
85        /// [`Event::Sliced`].
86        len: u64,
87    },
88    /// Report progress to whoever is watching the job's event stream.
89    Emit {
90        /// Arbitrary JSON, forwarded verbatim to the job's subscribers.
91        progress: serde_json::Value,
92    },
93    /// Finish successfully with this payload.
94    Done {
95        /// The job's result, shaped by the spec's declared output.
96        result: serde_json::Value,
97    },
98    /// Give up. The job ends with this code and message, and no result.
99    Fail {
100        /// Machine-readable code; see [`error_codes`].
101        code: String,
102        /// Human-readable explanation.
103        message: String,
104    },
105}
106
107/// What the host feeds back into the guest's `step` export after carrying out a
108/// [`Command`].
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
110#[serde(tag = "event", rename_all = "snake_case")]
111pub enum Event {
112    /// Generation finished.
113    InferDone {
114        /// The generated text.
115        text: String,
116        /// How many tokens were produced. May be fewer than the requested
117        /// `max_tokens` if the guest ended generation early.
118        tokens_out: u32,
119    },
120    /// A file was opened.
121    Opened {
122        /// Use this in subsequent [`Command::Slice`] calls.
123        handle: Handle,
124        /// Total size of the file, in bytes.
125        len: u64,
126    },
127    /// A window of a file was read.
128    Sliced {
129        /// The window's contents.
130        text: String,
131        /// Where the returned text actually ended.
132        ///
133        /// This is **not** always `offset + len` from the request: the host cuts
134        /// a window back to a UTF-8 character boundary, because a caller picking
135        /// window sizes has no idea where characters begin, and a naive split
136        /// would corrupt a multi-byte character at nearly every seam. A guest
137        /// walking a file must resume from this value rather than advancing by
138        /// the length it asked for.
139        next_offset: u64,
140    },
141    /// Progress was forwarded. Carries nothing; it exists so `Emit` has a reply
142    /// and the command loop keeps its shape.
143    Emitted,
144}
145
146/// A guest's verdict on each streamed token, returned from its `on_token`
147/// export.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum TokenAction {
150    /// Keep generating.
151    Continue,
152    /// Stop generating now.
153    ///
154    /// A token or two may still arrive after this, because the verdict has to
155    /// travel back to the thread doing the generating.
156    Stop,
157}
158
159impl TokenAction {
160    /// Decode the raw `i32` a guest's `on_token` export returns.
161    ///
162    /// Anything that is not an explicit `Continue` reads as `Stop`. A guest
163    /// returning a value this crate does not recognise is malfunctioning, and
164    /// the safe reading of a malfunctioning guest is "stop", never "keep
165    /// spending tokens" — the same fail-closed posture as the capability checks.
166    pub fn from_i32(v: i32) -> Self {
167        if v == 0 {
168            Self::Continue
169        } else {
170            Self::Stop
171        }
172    }
173
174    /// Encode for the wasm boundary.
175    ///
176    /// These integers are part of the ABI: renumbering them silently changes the
177    /// meaning of every already-compiled block.
178    pub fn as_i32(self) -> i32 {
179        match self {
180            Self::Continue => 0,
181            Self::Stop => 1,
182        }
183    }
184}
185
186/// Where a job is in its lifecycle.
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case")]
189pub enum JobStatus {
190    /// Accepted, not yet started.
191    Queued,
192    /// Executing.
193    Running,
194    /// Finished with a result.
195    Completed,
196    /// Finished with an error and no result.
197    Failed,
198    /// Stopped by request.
199    Cancelled,
200}
201
202impl JobStatus {
203    /// Whether this status is final — nothing further will happen to the job.
204    ///
205    /// Clients poll until this is true. Adding a new non-terminal status is
206    /// therefore safe, while a new terminal one that is missing from this match
207    /// leaves callers waiting forever.
208    pub fn is_terminal(self) -> bool {
209        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
210    }
211}
212
213/// What a job cost.
214#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
215pub struct Usage {
216    /// Tokens consumed by prompts.
217    pub tokens_in: u32,
218    /// Tokens generated.
219    pub tokens_out: u32,
220    /// Wall-clock duration of the job.
221    pub duration_ms: u64,
222    /// Which model served the job's inference.
223    pub model: String,
224}
225
226/// The fixed, spec-independent envelope handed back to the calling agent.
227///
228/// Every job returns this shape regardless of what it did, so an agent can
229/// handle results without knowing anything about the block that produced them.
230/// Only `result` varies, and its shape is that job's business.
231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
232pub struct Envelope {
233    /// Lifecycle state; see [`JobStatus::is_terminal`].
234    pub status: JobStatus,
235    /// Present only when the job completed.
236    ///
237    /// A failed or cancelled job never carries a partial result: a caller must
238    /// never have to guess whether a payload is trustworthy.
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub result: Option<serde_json::Value>,
241    /// Present only when the job failed or was cancelled.
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub error: Option<JobError>,
244    /// Cost accounting, populated even for failed jobs — work already spent
245    /// still counts.
246    pub usage: Usage,
247}
248
249/// Why a job did not complete.
250#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
251pub struct JobError {
252    /// Machine-readable; see [`error_codes`].
253    pub code: String,
254    /// Human-readable detail.
255    pub message: String,
256}
257
258/// The error codes the daemon emits in [`JobError::code`].
259///
260/// These are string constants rather than an enum so the set can grow without
261/// breaking clients that match on strings, and so a client built against an
262/// older version meets an unfamiliar code rather than a decode failure.
263pub mod error_codes {
264    /// The job's model could not be loaded or served.
265    pub const MODEL_LOAD_FAILED: &str = "model_load_failed";
266    /// A guest tried to reach something its spec does not grant.
267    pub const CAPABILITY_DENIED: &str = "capability_denied";
268    /// Job input did not match the spec's declared shape.
269    pub const SCHEMA_VALIDATION_FAILED: &str = "schema_validation_failed";
270    /// The guest trapped — a panic, a bad export signature, or malformed wasm.
271    pub const WASM_TRAP: &str = "wasm_trap";
272    /// The job exceeded its time budget.
273    pub const TIMEOUT: &str = "timeout";
274    /// The job was cancelled by request.
275    pub const CANCELLED: &str = "cancelled";
276}