Skip to main content

byob_plugins/
native.rs

1//! Native plugin backends: in-tree Rust that plays a plugin backend's part inside byob's own
2//! process, with the same message vocabulary as the JSON-lines wire format
3//! (`docs/plugins-backend.md`).
4//!
5//! A manifest names one with `"backend": {"native": "<id>"}`; the app registers a
6//! [`NativeFactory`] under that id (`byob_agent::Natives::backend`). The engine's backend host
7//! ([`crate::backend::Backend`]) runs it exactly like a process backend: made on first use, on
8//! a thread of its own (so tool calls and view requests never block the engine thread), sent
9//! the same `init`, `event`, `request`, `cancel` and `shutdown` messages, answering with the
10//! same `reply`, `view`, `notify`, `notice`, `log`, `fault`, `subscribe`, `artifact` and `web`
11//! messages through an [`Outbox`]. A panic is a crash: the host catches it, records a fault,
12//! drops the backend's state and makes a fresh one from the factory (with the process
13//! backends' backoff and crash limit). Reload, switch-off and removal drop it.
14//!
15//! [`serve_stdio`] runs the same backend as a JSON-lines process instead, so one
16//! implementation can be tested (or benchmarked) against a process backend's harness.
17
18use std::io::{BufRead, Write};
19use std::sync::Arc;
20
21use serde_json::{Value, json};
22
23/// A plugin backend in byob's process (see the module docs). Calls come one at a time, in
24/// order, on the backend's own thread.
25pub trait NativeBackend: Send {
26    /// The `init` message: first after every (re)start (`restart` counts earlier starts).
27    fn init(&mut self, init: &Value, out: &Outbox) {
28        let _ = (init, out);
29    }
30
31    /// An `event` (`event`: `client`, `view`, `web`, or a subscribed engine event).
32    fn event(&mut self, event: &Value, out: &Outbox) {
33        let _ = (event, out);
34    }
35
36    /// A `request` (`method`: `tool`, `view`, `hook`, `web`): its `reply`'s result, or the
37    /// error text. Messages sent through `out` meanwhile reach byob before the reply.
38    fn request(&mut self, method: &str, params: &Value, out: &Outbox) -> Result<Value, String>;
39
40    /// After the reply to a request went out (e.g. draw views again after a tool call).
41    fn after_request(&mut self, method: &str, out: &Outbox) {
42        let _ = (method, out);
43    }
44
45    /// `shutdown`: byob stops it (reload, switched off, removed); nothing more comes.
46    fn shutdown(&mut self) {}
47}
48
49/// Makes a fresh backend (on every start and restart).
50pub type NativeFactory = Arc<dyn Fn() -> Box<dyn NativeBackend> + Send + Sync>;
51
52/// Where a native backend's messages go (byob's side of the wire).
53#[derive(Clone)]
54pub struct Outbox(Arc<dyn Fn(Value) + Send + Sync>);
55
56impl Outbox {
57    pub fn new(send: impl Fn(Value) + Send + Sync + 'static) -> Self {
58        Self(Arc::new(send))
59    }
60
61    /// Any message (`{"type": …, …}`).
62    pub fn send(&self, msg: Value) {
63        (self.0)(msg);
64    }
65
66    /// A view's whole new tree (`values` and `client`: fields set for one client).
67    pub fn view(&self, id: &str, key: &str, tree: Value) {
68        self.send(json!({"type": "view", "id": id, "key": key, "view": tree}));
69    }
70
71    /// Forwarded to clients over the plugin channel (all of them without `client`).
72    pub fn notify(&self, msg: Value, client: Option<u64>) {
73        self.send(json!({"type": "notify", "msg": msg, "client": client}));
74    }
75
76    /// A notice in clients (`level`: `info` or `error`).
77    pub fn notice(&self, text: &str, level: &str) {
78        self.send(json!({"type": "notice", "text": text, "level": level}));
79    }
80
81    /// A line in its log (the stderr ring `plugin_status` shows).
82    pub fn log(&self, text: &str) {
83        self.send(json!({"type": "log", "text": text}));
84    }
85
86    /// A problem for the model and the user (kept in its status).
87    pub fn fault(&self, text: &str) {
88        self.send(json!({"type": "fault", "text": text}));
89    }
90
91    /// Engine events to get from now on.
92    pub fn subscribe(&self, events: &[&str]) {
93        self.send(json!({"type": "subscribe", "events": events}));
94    }
95}
96
97/// Hand one message from byob to `backend` (the wire format's dispatch, shared by the
98/// in-process host and [`serve_stdio`]). `false` after `shutdown`.
99pub fn dispatch(backend: &mut dyn NativeBackend, msg: &Value, out: &Outbox) -> bool {
100    match msg.get("type").and_then(Value::as_str).unwrap_or_default() {
101        "init" => backend.init(msg, out),
102        "event" => backend.event(msg, out),
103        "request" => {
104            let id = msg.get("id").cloned().unwrap_or(Value::Null);
105            let method = msg.get("method").and_then(Value::as_str).unwrap_or_default();
106            let params = msg.get("params").unwrap_or(&Value::Null);
107            let reply = match backend.request(method, params, out) {
108                Ok(result) => json!({"type": "reply", "id": id, "result": result}),
109                Err(error) => json!({"type": "reply", "id": id, "error": error}),
110            };
111            out.send(reply);
112            backend.after_request(method, out);
113        }
114        "shutdown" => {
115            backend.shutdown();
116            return false;
117        }
118        // `cancel` (a call can't be interrupted; its late reply is dropped) and newer types
119        _ => {}
120    }
121    true
122}
123
124/// Serve `backend` as a JSON-lines process on stdin/stdout until stdin closes or byob sends
125/// `shutdown`.
126pub fn serve_stdio(mut backend: Box<dyn NativeBackend>) -> std::io::Result<()> {
127    let stdout = std::sync::Mutex::new(std::io::stdout());
128    let out = Outbox::new(move |msg| {
129        if let Ok(mut line) = serde_json::to_vec(&msg) {
130            line.push(b'\n');
131            let mut w = stdout.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
132            let _ = w.write_all(&line).and_then(|()| w.flush());
133        }
134    });
135    let stdin = std::io::stdin();
136    for line in stdin.lock().lines() {
137        let line = line?;
138        let Ok(msg) = serde_json::from_str::<Value>(line.trim()) else { continue };
139        if !msg.is_object() {
140            continue;
141        }
142        if !dispatch(backend.as_mut(), &msg, &out) {
143            break;
144        }
145    }
146    Ok(())
147}
148
149/// A panic's message.
150pub fn panic_text(payload: &(dyn std::any::Any + Send)) -> String {
151    payload.downcast_ref::<&str>().map(|s| (*s).to_owned()).or_else(|| payload.downcast_ref::<String>().cloned()).unwrap_or_else(|| "(no message)".into())
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use std::sync::Mutex;
158
159    struct Echo;
160    impl NativeBackend for Echo {
161        fn request(&mut self, method: &str, params: &Value, out: &Outbox) -> Result<Value, String> {
162            out.log("working");
163            match method {
164                "tool" => Ok(params.get("args").cloned().unwrap_or(Value::Null)),
165                _ => Err(format!("no {method}")),
166            }
167        }
168        fn after_request(&mut self, _: &str, out: &Outbox) {
169            out.view("v", "", json!({"type": "text", "text": "after"}));
170        }
171    }
172
173    #[test]
174    fn dispatch_replies_in_order() {
175        let seen = Arc::new(Mutex::new(Vec::new()));
176        let s = Arc::clone(&seen);
177        let out = Outbox::new(move |m| s.lock().unwrap().push(m));
178        let mut b = Echo;
179        assert!(dispatch(&mut b, &json!({"type": "request", "id": 3, "method": "tool", "params": {"args": {"x": 1}}}), &out));
180        assert!(dispatch(&mut b, &json!({"type": "request", "id": 4, "method": "nope"}), &out));
181        assert!(!dispatch(&mut b, &json!({"type": "shutdown"}), &out));
182        let seen = seen.lock().unwrap();
183        let types: Vec<&str> = seen.iter().map(|m| m["type"].as_str().unwrap()).collect();
184        assert_eq!(types, ["log", "reply", "view", "log", "reply", "view"]);
185        assert_eq!(seen[1]["result"], json!({"x": 1}));
186        assert_eq!(seen[4]["error"], "no nope");
187    }
188}