Skip to main content

salvor_server/
executor.rs

1//! The model-executor seam: the general injection point a host supplies so the
2//! server can perform a model call on a client-driven run's behalf.
3//!
4//! # Why it is a seam, not baked in
5//!
6//! This mirrors the [`AgentFactory`](crate::AgentFactory) decision exactly. The
7//! server owns the *mechanism* of a durable model step (append the write-ahead
8//! intent, perform the call, append the completion, answer retries from the
9//! log), but it must not own the *policy* of which provider runs, where its
10//! credential comes from, or how the request is shaped. Salvor is for anyone
11//! building on it, browser or backend; aarg is only the first consumer. So the
12//! executor is a trait the embedding binary implements and injects, never a
13//! hard-wired provider.
14//!
15//! `salvor serve` wires a default [`LlmModelExecutor`] from its own
16//! client-construction path, so the feature works out of the box; another host
17//! (a future `aarg serve`) injects its own executor resolving a keychain
18//! credential, and nothing consumer-specific leaks into salvor-server.
19//!
20//! # The two methods
21//!
22//! - [`ModelExecutor::execute`] performs a single call and returns the assembled
23//!   [`MessageResponse`]. This backs the non-streaming model step.
24//! - [`ModelExecutor::open_stream`] opens the provider stream and returns a
25//!   [`ModelStream`] the server pumps: each event feeds a live ticker frame and
26//!   a [`MessageAccumulator`], and the assembled completion is recorded once at
27//!   the end. This backs the server-sent-events model step.
28//!
29//! The request crosses the seam as a raw [`Value`] (the caller's canonical
30//! request JSON), not a typed `MessageRequest`, because the server hashes and
31//! records exactly those bytes: the hash it recorded is the hash it sent.
32//!
33//! An executor error is a plain `String`, the same human-message convention the
34//! agent factory uses. A provider failure maps to the server error envelope
35//! without a completion being recorded, so the intent is left dangling (legal,
36//! the crash story) and the run stays drivable.
37
38use async_trait::async_trait;
39use salvor_llm::{Client, MessageResponse, MessageStream, StreamEvent};
40use serde_json::Value;
41
42/// Performs a model call on behalf of a client-driven run. Injected by the
43/// embedding binary, exactly like [`AgentFactory`](crate::AgentFactory).
44#[async_trait]
45pub trait ModelExecutor: Send + Sync {
46    /// Perform a single (non-streaming) model call and return the response.
47    ///
48    /// # Errors
49    ///
50    /// A human message when the provider call fails. The server maps it to the
51    /// error envelope and records no completion.
52    async fn execute(&self, request: Value) -> Result<MessageResponse, String>;
53
54    /// Open a streaming model call, returning a [`ModelStream`] of provider
55    /// events the server pumps for the live ticker.
56    ///
57    /// # Errors
58    ///
59    /// A human message when the stream cannot be opened.
60    async fn open_stream(&self, request: Value) -> Result<Box<dyn ModelStream>, String>;
61}
62
63/// A stream of provider events opened by [`ModelExecutor::open_stream`].
64///
65/// It mirrors `salvor_llm::MessageStream`: pull one typed event at a time until
66/// `None`. The server feeds each event to a [`MessageAccumulator`] so the
67/// recorded completion is byte-identical to the non-streaming path.
68#[async_trait]
69pub trait ModelStream: Send {
70    /// The next provider event, or `None` once the stream is exhausted. An
71    /// `Err(String)` is a mid-stream failure: the server surfaces it and records
72    /// no completion, leaving the intent dangling for a safe re-issue on resume.
73    async fn next_event(&mut self) -> Option<Result<StreamEvent, String>>;
74}
75
76/// The default [`ModelExecutor`], wrapping a general `salvor_llm::Client`.
77///
78/// This is not consumer-specific: it wraps the general model transport, so any
79/// host that reaches a provider through `salvor-llm` gets a working executor by
80/// constructing a [`Client`] and handing it here. `salvor serve` builds one from
81/// its environment client-construction path.
82pub struct LlmModelExecutor {
83    client: Client,
84}
85
86impl LlmModelExecutor {
87    /// Wraps `client` as a model executor.
88    #[must_use]
89    pub fn new(client: Client) -> Self {
90        Self { client }
91    }
92}
93
94#[async_trait]
95impl ModelExecutor for LlmModelExecutor {
96    async fn execute(&self, request: Value) -> Result<MessageResponse, String> {
97        // Forward the caller's request bytes verbatim: the hash the server
98        // recorded over `request` is the hash it sends.
99        self.client
100            .send_message_value(&request)
101            .await
102            .map_err(|error| error.to_string())
103    }
104
105    async fn open_stream(&self, request: Value) -> Result<Box<dyn ModelStream>, String> {
106        let stream = self
107            .client
108            .stream_message_value(&request)
109            .await
110            .map_err(|error| error.to_string())?;
111        Ok(Box::new(LlmModelStream { inner: stream }))
112    }
113}
114
115/// The [`ModelStream`] over a `salvor_llm::MessageStream`.
116struct LlmModelStream {
117    inner: MessageStream,
118}
119
120#[async_trait]
121impl ModelStream for LlmModelStream {
122    async fn next_event(&mut self) -> Option<Result<StreamEvent, String>> {
123        self.inner
124            .next_event()
125            .await
126            .map(|event| event.map_err(|error| error.to_string()))
127    }
128}