Skip to main content

molo_core/
run.rs

1//! Structured run protocol shared by agent implementations and callers.
2//!
3//! The structured entry point is [`RunRequest`] + [`RunContext`] →
4//! [`RunOutput`]. The core crate defines the protocol types only; agent
5//! runtime crates can layer text helpers on top.
6//!
7//! # Examples
8//!
9//! ```rust
10//! # extern crate molo_core as molo;
11//! use molo_core::message::Message;
12//! use molo_core::run::{RunContext, RunOutput, RunRequest, RunSummary};
13//! use std::time::Duration;
14//!
15//! let request = RunRequest::text("hi");
16//! let context = RunContext::new("request-42").with_timeout(Duration::from_secs(30));
17//! let output = RunOutput {
18//!     run_id: context.run_id.clone(),
19//!     answer: "Hello".into(),
20//!     summary: RunSummary {
21//!         rounds: 1,
22//!         ..RunSummary::default()
23//!     },
24//!     final_message: Message::assistant("Hello"),
25//!     artifacts: vec![],
26//!     metadata: Default::default(),
27//! };
28//!
29//! assert_eq!(request.input.as_text(), Some("hi"));
30//! assert_eq!(output.run_id, "request-42");
31//! assert_eq!(output.answer, "Hello");
32//! assert_eq!(output.summary.rounds, 1);
33//! ```
34
35use crate::message::{ContentBlock, Message};
36use crate::provider::{FinishReason, ModelOptions, Usage};
37use serde::{Deserialize, Serialize};
38use std::collections::BTreeMap;
39use std::fmt;
40use std::sync::OnceLock;
41use std::sync::atomic::{AtomicU64, Ordering};
42use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
43use tokio_util::sync::CancellationToken;
44
45/// Request, context, or output metadata for one run.
46///
47/// Metadata is caller- or implementation-owned key/value data. It is not
48/// automatically inserted into model context; an agent, harness, provider
49/// adapter, or application layer must opt in explicitly.
50pub type RunMetadata = BTreeMap<String, serde_json::Value>;
51
52/// User input accepted by a run.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[non_exhaustive]
55pub enum UserInput {
56    /// Plain text input, equivalent to [`Message::user`].
57    Text(String),
58    /// Multi-block user input, equivalent to [`Message::user_blocks`].
59    Blocks(Vec<ContentBlock>),
60}
61
62impl UserInput {
63    /// Constructs plain text input.
64    pub fn text(input: impl Into<String>) -> Self {
65        Self::Text(input.into())
66    }
67
68    /// Constructs multi-block input.
69    pub fn blocks(blocks: Vec<ContentBlock>) -> Self {
70        Self::Blocks(blocks)
71    }
72
73    /// Converts this input into the user [`Message`] recorded for the run.
74    pub fn into_message(self) -> Message {
75        match self {
76            Self::Text(input) => Message::user(input),
77            Self::Blocks(blocks) => Message::user_blocks(blocks),
78        }
79    }
80
81    /// Returns the inner text when this input is plain text.
82    ///
83    /// Multi-block inputs return `None`; callers that need to inspect those
84    /// should match on [`UserInput`] and handle blocks explicitly.
85    pub fn as_text(&self) -> Option<&str> {
86        match self {
87            Self::Text(input) => Some(input),
88            Self::Blocks(_) => None,
89        }
90    }
91}
92
93impl From<String> for UserInput {
94    fn from(input: String) -> Self {
95        Self::Text(input)
96    }
97}
98
99impl From<&str> for UserInput {
100    fn from(input: &str) -> Self {
101        Self::Text(input.to_string())
102    }
103}
104
105impl From<Vec<ContentBlock>> for UserInput {
106    fn from(blocks: Vec<ContentBlock>) -> Self {
107        Self::Blocks(blocks)
108    }
109}
110
111/// Input and model parameters for one run.
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113pub struct RunRequest {
114    /// User input to record and send into the agent loop.
115    pub input: UserInput,
116    /// Request-scoped model options.
117    ///
118    /// `None` uses the agent's configured defaults. `Some(options)` replaces
119    /// the configured defaults for this run. Typed output schemas override
120    /// `options.structured`.
121    pub options: Option<ModelOptions>,
122    /// Caller-owned metadata for this run request.
123    pub metadata: RunMetadata,
124}
125
126impl RunRequest {
127    /// Constructs a text request.
128    pub fn text(input: impl Into<String>) -> Self {
129        Self {
130            input: UserInput::text(input),
131            options: None,
132            metadata: RunMetadata::new(),
133        }
134    }
135
136    /// Constructs a multi-block request.
137    pub fn blocks(blocks: Vec<ContentBlock>) -> Self {
138        Self {
139            input: UserInput::blocks(blocks),
140            options: None,
141            metadata: RunMetadata::new(),
142        }
143    }
144
145    /// Sets the model options for this request.
146    pub fn with_options(mut self, options: ModelOptions) -> Self {
147        self.options = Some(options);
148        self
149    }
150
151    /// Sets caller-owned metadata for this request.
152    pub fn with_metadata(mut self, metadata: RunMetadata) -> Self {
153        self.metadata = metadata;
154        self
155    }
156}
157
158impl From<String> for RunRequest {
159    fn from(input: String) -> Self {
160        Self::text(input)
161    }
162}
163
164impl From<&str> for RunRequest {
165    fn from(input: &str) -> Self {
166        Self::text(input)
167    }
168}
169
170impl From<UserInput> for RunRequest {
171    fn from(input: UserInput) -> Self {
172        Self {
173            input,
174            options: None,
175            metadata: RunMetadata::new(),
176        }
177    }
178}
179
180/// Execution controls and host-owned metadata for one run.
181#[derive(Clone)]
182pub struct RunContext {
183    /// Stable correlation id for this run.
184    pub run_id: String,
185    /// Cooperative cancellation source for this run.
186    pub cancellation: CancellationToken,
187    /// Optional wall-clock deadline for the run.
188    pub deadline: Option<Instant>,
189    /// Host-owned execution metadata.
190    pub metadata: RunMetadata,
191}
192
193impl fmt::Debug for RunContext {
194    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195        f.debug_struct("RunContext")
196            .field("run_id", &self.run_id)
197            .field("cancellation", &"CancellationToken")
198            .field("deadline", &self.deadline)
199            .field("metadata", &self.metadata)
200            .finish()
201    }
202}
203
204impl RunContext {
205    /// Constructs a context with a generated process-local run id.
206    pub fn generated() -> Self {
207        Self::new(generated_run_id())
208    }
209
210    /// Constructs a context with a caller-provided run id.
211    pub fn new(run_id: impl Into<String>) -> Self {
212        Self {
213            run_id: run_id.into(),
214            cancellation: CancellationToken::new(),
215            deadline: None,
216            metadata: RunMetadata::new(),
217        }
218    }
219
220    /// Sets the cooperative cancellation token.
221    pub fn with_cancellation(mut self, token: CancellationToken) -> Self {
222        self.cancellation = token;
223        self
224    }
225
226    /// Sets an absolute wall-clock deadline.
227    pub fn with_deadline(mut self, deadline: Instant) -> Self {
228        self.deadline = Some(deadline);
229        self
230    }
231
232    /// Sets a deadline relative to now.
233    pub fn with_timeout(self, timeout: Duration) -> Self {
234        self.with_deadline(Instant::now() + timeout)
235    }
236
237    /// Sets host-owned execution metadata.
238    pub fn with_metadata(mut self, metadata: RunMetadata) -> Self {
239        self.metadata = metadata;
240        self
241    }
242
243    /// Whether cancellation has been requested.
244    pub fn is_cancelled(&self) -> bool {
245        self.cancellation.is_cancelled()
246    }
247
248    /// Whether the deadline has elapsed.
249    pub fn is_expired(&self) -> bool {
250        self.deadline
251            .is_some_and(|deadline| Instant::now() >= deadline)
252    }
253
254    /// Remaining wall-clock time before the deadline.
255    ///
256    /// Returns `None` when no deadline is set, and `Some(Duration::ZERO)`
257    /// after the deadline has elapsed.
258    pub fn remaining(&self) -> Option<Duration> {
259        self.deadline
260            .map(|deadline| deadline.saturating_duration_since(Instant::now()))
261    }
262}
263
264/// Creates a process-local unique run id.
265pub(crate) fn generated_run_id() -> String {
266    static START_NANOS: OnceLock<u128> = OnceLock::new();
267    static PROCESS_RUN_COUNTER: AtomicU64 = AtomicU64::new(0);
268
269    let start_nanos = *START_NANOS.get_or_init(|| {
270        SystemTime::now()
271            .duration_since(UNIX_EPOCH)
272            .unwrap_or_default()
273            .as_nanos()
274    });
275    let n = PROCESS_RUN_COUNTER.fetch_add(1, Ordering::Relaxed);
276    format!("run-{start_nanos}-{n}")
277}
278
279/// A handle to an artifact produced by a run.
280///
281/// Artifacts are references, not storage: this type carries no bytes and does
282/// not define persistence, cleanup, or permissions.
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284pub struct Artifact {
285    /// Artifact id, unique within the producing application or store.
286    pub id: String,
287    /// Optional human-facing label.
288    pub label: Option<String>,
289    /// Optional MIME type.
290    pub mime_type: Option<String>,
291    /// Optional application- or store-owned URI.
292    pub uri: Option<String>,
293    /// Artifact metadata.
294    pub metadata: RunMetadata,
295}
296
297/// Execution summary for one run.
298#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
299pub struct RunSummary {
300    /// Number of conversation rounds.
301    pub rounds: usize,
302    /// Total number of tool executions.
303    pub tool_calls: usize,
304    /// Sum of reported token usage across provider turns.
305    ///
306    /// Always the sum of the parts that were reported: turns whose provider
307    /// did not return usage contribute nothing. Only exact when
308    /// [`usage_omitted`](Self::usage_omitted) is `false`; otherwise it is a
309    /// lower bound.
310    pub usage: Usage,
311    /// `true` = at least one provider turn did not report usage (the endpoint
312    /// omitted it), so [`usage`](Self::usage) is a lower bound rather than
313    /// the run's exact usage.
314    pub usage_omitted: bool,
315    /// Final direct-answer provider finish reason, when available.
316    pub finish_reason: Option<FinishReason>,
317    /// Wall-clock run latency.
318    pub latency: Duration,
319    /// Provider model identifier, when the provider exposes one.
320    pub provider_model: Option<String>,
321}
322
323/// Structured result of one non-streaming run.
324#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
325pub struct RunOutput {
326    /// Run id copied from the [`RunContext`].
327    pub run_id: String,
328    /// Final assistant answer text.
329    pub answer: String,
330    /// Execution summary.
331    pub summary: RunSummary,
332    /// Final assistant message.
333    pub final_message: Message,
334    /// Artifact handles produced by this run.
335    pub artifacts: Vec<Artifact>,
336    /// Implementation-owned output metadata.
337    pub metadata: RunMetadata,
338}
339
340/// Typed-output result paired with the raw structured run output.
341#[derive(Debug, Clone, PartialEq)]
342pub struct TypedRunOutput<T> {
343    /// Deserialized typed value.
344    pub value: T,
345    /// Raw run output, including the JSON text in [`RunOutput::answer`].
346    pub output: RunOutput,
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use serde_json::json;
353
354    #[test]
355    fn user_input_converts_to_message() {
356        assert_eq!(UserInput::text("hi").into_message(), Message::user("hi"));
357        let blocks = vec![ContentBlock::Text("hi".into())];
358        assert_eq!(
359            UserInput::blocks(blocks.clone()).into_message(),
360            Message::user_blocks(blocks)
361        );
362    }
363
364    #[test]
365    fn run_request_builders_set_fields() {
366        let mut metadata = RunMetadata::new();
367        metadata.insert("trace".into(), json!("abc"));
368        let request = RunRequest::text("hi")
369            .with_options(ModelOptions {
370                temperature: Some(0.1),
371                ..Default::default()
372            })
373            .with_metadata(metadata.clone());
374
375        assert_eq!(request.input, UserInput::text("hi"));
376        assert_eq!(
377            request.options.as_ref().and_then(|o| o.temperature),
378            Some(0.1)
379        );
380        assert_eq!(request.metadata, metadata);
381    }
382
383    #[test]
384    fn run_context_helpers() {
385        let a = RunContext::generated();
386        let b = RunContext::generated();
387        assert_ne!(a.run_id, b.run_id);
388        assert!(a.run_id.starts_with("run-"));
389
390        let named = RunContext::new("request-42");
391        assert_eq!(named.run_id, "request-42");
392
393        let token = CancellationToken::new();
394        let cancelled = RunContext::new("cancel").with_cancellation(token.clone());
395        token.cancel();
396        assert!(cancelled.is_cancelled());
397
398        let expired = RunContext::new("expired").with_deadline(Instant::now());
399        assert!(expired.is_expired());
400        assert_eq!(expired.remaining(), Some(Duration::ZERO));
401    }
402}