Skip to main content

car_inference/
runner.rs

1//! Foreign-implemented inference runner.
2//!
3//! Closes [Parslee-ai/car-releases#24]. The runner pattern lets a host
4//! (Node.js, Python, Swift, Kotlin, or a remote JSON-RPC client) own
5//! the wire format for cloud chat APIs while CAR stays in the
6//! lifecycle path — observing every event, applying policy, recording
7//! to the eventlog, supporting replay.
8//!
9//! When a model schema declares `source: ModelSource::Delegated { .. }`,
10//! [`InferenceEngine::generate_tracked_stream`](crate::InferenceEngine::generate_tracked_stream) checks the
11//! process-wide runner slot ([`set_inference_runner`]); if a runner
12//! is installed, the request is handed to it and its emitted events
13//! flow through the stream's `Receiver<StreamEvent>` exactly as if a
14//! native backend had produced them.
15//!
16//! The two-direction contract:
17//! - Rust → host: `run(request)` is invoked with a fully-formed
18//!   [`GenerateRequest`]. The trait is async so the host can do its
19//!   own HTTP / SDK work. The host receives an event emitter it
20//!   should call as chunks arrive.
21//! - host → Rust: every chunk becomes a `StreamEvent` via the
22//!   provided emitter. Final result returned as the trait method's
23//!   return value (text + tool_calls).
24//!
25//! Only one runner can be registered at a time (mirrors
26//! `car_multi::AgentRunner`'s singleton constraint). Re-registering
27//! overwrites the slot.
28//!
29//! [Parslee-ai/car-releases#24]: https://github.com/Parslee-ai/car-releases/issues/24
30
31use std::sync::{Arc, OnceLock, RwLock};
32
33use serde::{Deserialize, Serialize};
34
35use crate::stream::StreamEvent;
36use crate::tasks::generate::GenerateRequest;
37use crate::InferenceError;
38
39/// Final result returned by the runner once the stream is complete.
40/// Mirrors what [`crate::StreamAccumulator::finish`] yields for native
41/// backends — runners are expected to aggregate as they emit so the
42/// returned text matches the concatenation of `TextDelta`s.
43#[derive(Debug, Clone, Serialize, Deserialize, Default)]
44pub struct RunnerResult {
45    pub text: String,
46    #[serde(default)]
47    pub tool_calls: Vec<crate::tasks::generate::ToolCall>,
48}
49
50/// Errors a runner can surface back to the engine. Kept as a string
51/// payload so foreign hosts (NAPI, PyO3, UniFFI) can populate it
52/// without sharing concrete error types.
53#[derive(Debug, Clone, thiserror::Error)]
54pub enum RunnerError {
55    /// The runner declined the request (e.g., the schema's `hint`
56    /// didn't match any provider it knows about).
57    #[error("runner declined: {0}")]
58    Declined(String),
59    /// The runner attempted the request but failed at the wire layer
60    /// (HTTP error, auth failure, provider rate limit, etc.).
61    #[error("runner failed: {0}")]
62    Failed(String),
63}
64
65impl From<RunnerError> for InferenceError {
66    fn from(value: RunnerError) -> Self {
67        InferenceError::InferenceFailed(value.to_string())
68    }
69}
70
71/// Sink the runner uses to emit stream events as they arrive from the
72/// upstream provider. The runner calls `emit(event)` for every chunk;
73/// CAR receives it through the [`StreamEvent`] channel returned from
74/// [`crate::InferenceEngine::generate_tracked_stream`].
75///
76/// Cloning shares the underlying channel — a runner that fans out
77/// (e.g., separate workers per tool call) can clone the emitter once
78/// per worker.
79#[derive(Clone)]
80pub struct EventEmitter {
81    tx: tokio::sync::mpsc::Sender<StreamEvent>,
82}
83
84impl EventEmitter {
85    pub(crate) fn new(tx: tokio::sync::mpsc::Sender<StreamEvent>) -> Self {
86        Self { tx }
87    }
88
89    /// Emit one event. Best-effort: if the receiver was dropped
90    /// (caller stopped listening), the event is silently discarded.
91    /// The runner can detect this via [`Self::is_closed`] and bail
92    /// early if it wants to stop the upstream call.
93    pub async fn emit(&self, event: StreamEvent) {
94        let _ = self.tx.send(event).await;
95    }
96
97    /// Has the receiver been dropped? Runners that pump events
98    /// indefinitely should poll this to stop on consumer cancellation.
99    pub fn is_closed(&self) -> bool {
100        self.tx.is_closed()
101    }
102}
103
104/// The trait foreign hosts implement to handle delegated inference.
105///
106/// Implementations are typically thin shims around a host-owned SDK
107/// (Vercel AI SDK on Node, Anthropic's Python client, etc.). They
108/// translate the supplied [`GenerateRequest`] to a wire-format call,
109/// stream chunks back through the [`EventEmitter`], and return a
110/// [`RunnerResult`] with the aggregated final text.
111#[async_trait::async_trait]
112pub trait InferenceRunner: Send + Sync {
113    /// Drive the request to completion. The runner MUST emit at
114    /// least a final [`StreamEvent::Done`] event before returning,
115    /// for parity with native backends — consumers of the resulting
116    /// stream rely on `Done` as the terminal marker.
117    async fn run(
118        &self,
119        request: GenerateRequest,
120        emitter: EventEmitter,
121    ) -> Result<RunnerResult, RunnerError>;
122}
123
124fn runner_slot() -> &'static RwLock<Option<Arc<dyn InferenceRunner>>> {
125    static SLOT: OnceLock<RwLock<Option<Arc<dyn InferenceRunner>>>> = OnceLock::new();
126    SLOT.get_or_init(|| RwLock::new(None))
127}
128
129/// Serializes every test that sets or relies on the process-wide runner slot:
130/// `cargo test -p car-inference --lib` runs them as threads of one process, so
131/// without it one test's `set_inference_runner(None)` clears the runner
132/// another has just installed.
133#[cfg(test)]
134pub(crate) fn test_slot_lock() -> &'static tokio::sync::Mutex<()> {
135    static LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
136    &LOCK
137}
138
139/// Install a process-wide inference runner. Re-registering overwrites
140/// any previous runner. Pass `None` to clear the slot (rarely useful
141/// in production, but handy in tests).
142pub fn set_inference_runner(runner: Option<Arc<dyn InferenceRunner>>) {
143    let mut guard = runner_slot()
144        .write()
145        .expect("inference runner slot poisoned");
146    *guard = runner;
147}
148
149/// Snapshot the currently registered runner (if any). Cheap clone of
150/// the `Arc`; safe to call from any thread.
151pub fn current_inference_runner() -> Option<Arc<dyn InferenceRunner>> {
152    runner_slot()
153        .read()
154        .expect("inference runner slot poisoned")
155        .clone()
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    struct EchoRunner;
163
164    #[async_trait::async_trait]
165    impl InferenceRunner for EchoRunner {
166        async fn run(
167            &self,
168            request: GenerateRequest,
169            emitter: EventEmitter,
170        ) -> Result<RunnerResult, RunnerError> {
171            let text = format!("echo:{}", request.prompt);
172            emitter.emit(StreamEvent::TextDelta(text.clone())).await;
173            emitter
174                .emit(StreamEvent::Done {
175                    text: text.clone(),
176                    tool_calls: vec![],
177                })
178                .await;
179            Ok(RunnerResult {
180                text,
181                tool_calls: vec![],
182            })
183        }
184    }
185
186    #[test]
187    fn slot_round_trips() {
188        // Use a fresh slot view: even though it's process-wide, the
189        // OnceLock stays initialised across tests in the same binary.
190        // Setting None first guarantees a clean state for this test.
191        let _slot = test_slot_lock().blocking_lock();
192        set_inference_runner(None);
193        assert!(current_inference_runner().is_none());
194        set_inference_runner(Some(Arc::new(EchoRunner)));
195        assert!(current_inference_runner().is_some());
196        set_inference_runner(None);
197        assert!(current_inference_runner().is_none());
198    }
199
200    #[tokio::test]
201    async fn runner_can_emit_then_finish() {
202        let runner: Arc<dyn InferenceRunner> = Arc::new(EchoRunner);
203        let (tx, mut rx) = tokio::sync::mpsc::channel::<StreamEvent>(8);
204        let emitter = EventEmitter::new(tx);
205        let request = GenerateRequest {
206            prompt: "hi".into(),
207            ..Default::default()
208        };
209        let result = runner.run(request, emitter).await.unwrap();
210        assert_eq!(result.text, "echo:hi");
211        // Drain emitted events.
212        let mut got = Vec::new();
213        // short-wait-ok: exit-condition drain; every event was sent before run() returned
214        while let Ok(evt) =
215            tokio::time::timeout(std::time::Duration::from_millis(20), rx.recv()).await
216        {
217            match evt {
218                Some(e) => got.push(e),
219                None => break,
220            }
221        }
222        assert_eq!(got.len(), 2);
223        assert!(matches!(got[0], StreamEvent::TextDelta(_)));
224        assert!(matches!(got[1], StreamEvent::Done { .. }));
225    }
226}