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}