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}