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}