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}