salvor-runtime 0.5.2

The Salvor runtime IO edge: the public RunCtx durability substrate, the Agent builder, budget enforcement, and the built-in agent loop
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
//! [`Runtime`]: the batteries-included entry points over the built-in loop.
//!
//! Four verbs, one per way a run can need driving:
//!
//! - [`start`](Runtime::start) mints a run id and drives a fresh run.
//! - [`recover`](Runtime::recover) re-drives an interrupted (crashed) run
//!   over its recorded log: recorded steps replay, execution continues live
//!   from the first unrecorded step. Driving an already-completed run this
//!   way replays it end to end and is the cheapest full divergence check.
//! - [`resume`](Runtime::resume) supplies input to a *parked* run (one whose
//!   log ends at a `Suspended` or `BudgetExceeded` event). The input is
//!   validated first: against the recorded suspension `input_schema` (see
//!   [`crate::validate`] for what validation means in v0.1), or against the
//!   budget-extension shape (see [`crate::budgets`]). Only then is it handed
//!   to the loop, which records it as the `Resumed` event at the parked
//!   position, through the cursor like every other event.
//! - [`resolve`](Runtime::resolve) is the one human-driven override: it
//!   records the completion of a dangling `Write` intent by hand, after a
//!   human has verified externally what the write actually did. It executes
//!   nothing and drives nothing; it appends exactly one `ToolCallCompleted`
//!   so the run leaves `NeedsReconciliation` and a later `recover` can
//!   continue it. Every other verb refuses a dangling write, by design; this
//!   verb is the sanctioned way past it.
//!
//! A `Runtime` owns the store handle plus the injected clock and random
//! source it builds each [`RunCtx`](crate::RunCtx) with. It holds no
//! per-run state at all: dropping it mid-run loses nothing, because every
//! event was persisted the moment it happened. That is the kill -9 story.

use std::collections::BTreeMap;
use std::sync::Arc;

use salvor_core::{
    Budget, Event, EventEnvelope, PendingCall, RunId, RunStatus, UnresolvedWrite, derive_state,
};
use salvor_store::EventStore;
use serde_json::Value;

use crate::agent::Agent;
use crate::budgets::validate_extension_input;
use crate::ctx::{ClockFn, RandomFn, RunCtx};
use crate::driver::{self, LoopOutcome};
use crate::error::RuntimeError;
use crate::validate::validate_against_schema;

/// Why a run parked instead of completing.
#[derive(Debug, Clone)]
pub enum ParkReason {
    /// A tool suspended the run, awaiting input matching the schema.
    Suspended {
        /// The recorded suspension reason.
        reason: String,
        /// The JSON Schema the resume input must satisfy.
        input_schema: Value,
    },
    /// A declared budget was crossed. Resume may carry an extension.
    BudgetExceeded {
        /// The crossed budget, with its effective limit.
        budget: Budget,
        /// The observed value that crossed it.
        observed: f64,
    },
}

/// How a drive of a run ended.
#[derive(Debug, Clone)]
pub enum RunOutcome {
    /// The run completed with this output.
    Completed {
        /// The run that completed.
        run_id: RunId,
        /// The recorded final output.
        output: Value,
    },
    /// The run is parked durably; it survives restarts and deploys, and
    /// [`Runtime::resume`] continues it once input arrives.
    Parked {
        /// The parked run.
        run_id: RunId,
        /// Why it parked.
        reason: ParkReason,
    },
}

/// The batteries-included runtime. See the module docs for the three verbs.
pub struct Runtime {
    store: Arc<dyn EventStore>,
    clock: ClockFn,
    random: RandomFn,
    /// Whether the [`RunCtx`](crate::RunCtx) this runtime builds records the
    /// full model request body. Off unless
    /// [`with_record_prompts`](Runtime::with_record_prompts) turns it on.
    record_prompts: bool,
    /// Correlation tags every [`RunCtx`](crate::RunCtx) this runtime builds
    /// stamps on a genuinely fresh run. Unset unless
    /// [`with_labels`](Runtime::with_labels) sets them.
    labels: Option<BTreeMap<String, String>>,
}

impl Runtime {
    /// A runtime over `store` with the default clock and OS randomness.
    #[must_use]
    pub fn new(store: Arc<dyn EventStore>) -> Self {
        Self::with_hooks(
            store,
            Arc::new(time::OffsetDateTime::now_utc),
            Arc::new(crate::ctx::os_random),
        )
    }

    /// A runtime with an injected clock and random source, handed to every
    /// [`RunCtx`](crate::RunCtx) it builds. Deterministic tests inject fixed
    /// functions so full event logs compare equal across runs.
    #[must_use]
    pub fn with_hooks(store: Arc<dyn EventStore>, clock: ClockFn, random: RandomFn) -> Self {
        Self {
            store,
            clock,
            random,
            record_prompts: false,
            labels: None,
        }
    }

    /// Turns on recording of the full model request body for every run this
    /// runtime drives, passed through to each [`RunCtx`](crate::RunCtx) it
    /// builds. Off by default. Chained builder style so
    /// [`new`](Self::new)/[`with_hooks`](Self::with_hooks) keep their
    /// signatures and every existing caller stays at off.
    ///
    /// This is PII-sensitive (the body may hold user data or secrets), which is
    /// why it is off unless an operator opts in. See
    /// [`RunCtx::with_record_prompts`](crate::RunCtx::with_record_prompts) for
    /// what recording means and the guarantee that it does not affect replay.
    #[must_use]
    pub fn with_record_prompts(mut self, record_prompts: bool) -> Self {
        self.record_prompts = record_prompts;
        self
    }

    /// Sets the correlation tags every run this runtime drives stamps on its
    /// `RunStarted`, passed through to each [`RunCtx`](crate::RunCtx) it
    /// builds. Unset by default. Chained builder style, mirroring
    /// [`with_record_prompts`](Self::with_record_prompts); see
    /// [`RunCtx::with_labels`](crate::RunCtx::with_labels) for the bounds
    /// that apply and when they are checked.
    #[must_use]
    pub fn with_labels(mut self, labels: BTreeMap<String, String>) -> Self {
        self.labels = Some(labels);
        self
    }

    /// Starts a fresh run of `agent` with `input`, under a newly minted
    /// run id.
    ///
    /// # Errors
    ///
    /// Everything [`start_with_id`](Self::start_with_id) returns.
    pub async fn start(&self, agent: &Agent, input: Value) -> Result<RunOutcome, RuntimeError> {
        self.start_with_id(agent, RunId::new(), input).await
    }

    /// Starts a fresh run under a caller-chosen run id (tests use this to
    /// make logs comparable across control and killed runs).
    ///
    /// # Errors
    ///
    /// [`RuntimeError::RunAlreadyStarted`] when the id already has history;
    /// otherwise whatever the loop surfaces ([`RuntimeError::Store`],
    /// [`RuntimeError::Model`], [`RuntimeError::Replay`], ...).
    pub async fn start_with_id(
        &self,
        agent: &Agent,
        run_id: RunId,
        input: Value,
    ) -> Result<RunOutcome, RuntimeError> {
        let log = self.store.read_log(run_id).await?;
        if !log.is_empty() {
            return Err(RuntimeError::RunAlreadyStarted { run_id });
        }
        let mut ctx = self.ctx(run_id, log)?;
        finish(run_id, driver::drive(&mut ctx, agent, &input).await?)
    }

    /// Re-drives an interrupted run: replays the recorded log, then
    /// continues live from the first unrecorded step. This is the
    /// post-crash verb; it supplies no new input.
    ///
    /// # Errors
    ///
    /// [`RuntimeError::UnknownRun`] when the id has no history;
    /// `RuntimeError::Replay(ReplayError::NeedsReconciliation)` when the log
    /// ends in a write intent with no completion (a human must resolve it);
    /// [`RuntimeError::Replay`] on any divergence.
    pub async fn recover(&self, agent: &Agent, run_id: RunId) -> Result<RunOutcome, RuntimeError> {
        let log = self.read_existing(run_id).await?;
        let mut ctx = self.ctx(run_id, log)?;
        finish(run_id, driver::drive(&mut ctx, agent, &Value::Null).await?)
    }

    /// Resumes a parked run with `input`.
    ///
    /// The run must be parked: its derived status must be `Suspended` or
    /// `BudgetExceeded`. The input is validated before anything is recorded:
    /// a suspension validates against its recorded `input_schema`, a budget
    /// crossing against the extension shape. On success, the loop re-drives
    /// the run; the input is recorded as `Resumed` at the parked position
    /// and becomes the pending tool's result (or the budget extension).
    ///
    /// # Errors
    ///
    /// [`RuntimeError::UnknownRun`], [`RuntimeError::NotParked`], or
    /// [`RuntimeError::ResumeInputRejected`]; then whatever the loop
    /// surfaces.
    pub async fn resume(
        &self,
        agent: &Agent,
        run_id: RunId,
        input: Value,
    ) -> Result<RunOutcome, RuntimeError> {
        let log = self.read_existing(run_id).await?;
        let state = derive_state(&log);
        match &state.status {
            RunStatus::Suspended { input_schema, .. } => {
                validate_against_schema(&input, input_schema)
                    .map_err(RuntimeError::ResumeInputRejected)?;
            }
            RunStatus::BudgetExceeded { .. } => {
                validate_extension_input(&input).map_err(RuntimeError::ResumeInputRejected)?;
            }
            other => {
                return Err(RuntimeError::NotParked {
                    run_id,
                    status: status_name(other).to_owned(),
                });
            }
        }
        let mut ctx = self.ctx(run_id, log)?;
        ctx.set_resume_input(input);
        finish(run_id, driver::drive(&mut ctx, agent, &Value::Null).await?)
    }

    /// Records, by hand, the completion of a dangling `Write` intent, after a
    /// human has verified externally what the write did.
    ///
    /// This is the concrete form of human resolution. A crash
    /// between a write's recorded intent and its completion derives to
    /// [`RunStatus::NeedsReconciliation`], which every automatic verb refuses:
    /// the write may or may not have reached its target, and the runtime will
    /// not guess. Once a human has checked, `resolve` appends the completion
    /// they observed (or the completion of the write they performed by hand),
    /// so replay treats the call as done and never re-executes it. The run is
    /// then recoverable through [`recover`](Self::recover) like any other.
    ///
    /// It takes the same care as [`resume`](Self::resume): the state is
    /// validated *before* anything is written, and exactly one event is
    /// appended. `output` is recorded verbatim as the tool's output; nothing
    /// executes and nothing else is driven.
    ///
    /// # Errors
    ///
    /// [`RuntimeError::UnknownRun`] when the id has no history;
    /// [`RuntimeError::NotReconcilable`] when the run's log does not end at a
    /// dangling write intent (so there is no completion to record);
    /// [`RuntimeError::Store`] when the append fails.
    pub async fn resolve(&self, run_id: RunId, output: Value) -> Result<RunId, RuntimeError> {
        let log = self.read_existing(run_id).await?;
        let state = derive_state(&log);
        // Only a dangling write derives to NeedsReconciliation, and it always
        // carries the pending tool intent whose completion is missing.
        let intent_seq = match (&state.status, &state.pending_call) {
            (RunStatus::NeedsReconciliation, Some(PendingCall::Tool { seq, .. })) => *seq,
            (other, _) => {
                return Err(RuntimeError::NotReconcilable {
                    run_id,
                    status: status_name(other).to_owned(),
                });
            }
        };
        // The completion correlates to the intent's sequence number and takes
        // the next contiguous log position, exactly as the cursor would have
        // recorded it had the process not died in between.
        let completion = Event::ToolCallCompleted {
            seq: intent_seq,
            output,
        };
        let envelope = EventEnvelope::new(run_id, state.next_seq, (self.clock)(), completion);
        self.store.append(&envelope).await?;
        crate::progress::emit_step(run_id, envelope.seq, &envelope.event);
        Ok(run_id)
    }

    /// Abandons a run: appends a terminal [`Event::RunAbandoned`] by hand,
    /// retiring a run deliberately without finishing or failing it.
    ///
    /// An operator action, not a driver action. It executes nothing, drives
    /// nothing, and needs no lease: abandonment is the sanctioned "we do not
    /// care about this run anymore" path, appended straight to the log the way
    /// [`resolve`](Self::resolve) appends its one completion. It is allowed for
    /// any non-terminal run, whatever state it parked or crashed in.
    ///
    /// When the run is parked at a dangling write (status
    /// [`RunStatus::NeedsReconciliation`]), the outstanding intent's position
    /// and tool ride on the event as
    /// [`unresolved_write`](Event::RunAbandoned::unresolved_write). The
    /// abandonment never claims the write question was answered: it records
    /// exactly which write was left unsettled, so the honesty the reconciliation
    /// refusal carried is preserved in the terminal record rather than erased.
    ///
    /// # Errors
    ///
    /// [`RuntimeError::UnknownRun`] when the id has no history;
    /// [`RuntimeError::AlreadyTerminal`] when the run already reached a terminal
    /// event (completed, failed, or previously abandoned), so there is nothing
    /// left to retire; [`RuntimeError::Store`] when the append fails.
    pub async fn abandon(
        &self,
        run_id: RunId,
        reason: Option<String>,
    ) -> Result<RunId, RuntimeError> {
        let log = self.read_existing(run_id).await?;
        let state = derive_state(&log);
        // Refuse a run that already reached a terminal event: there is nothing
        // left to abandon. Every non-terminal state is fair game.
        if matches!(
            state.status,
            RunStatus::Completed { .. } | RunStatus::Failed { .. } | RunStatus::Abandoned { .. }
        ) {
            return Err(RuntimeError::AlreadyTerminal {
                run_id,
                status: status_name(&state.status).to_owned(),
            });
        }
        // A run parked at a dangling write carries the outstanding intent
        // forward as recorded honesty: name the write whose effect stays
        // unknown rather than pretend it settled. Every other state abandons
        // with no unresolved-write evidence.
        let unresolved_write = match (&state.status, &state.pending_call) {
            (RunStatus::NeedsReconciliation, Some(PendingCall::Tool { seq, tool, .. })) => {
                Some(UnresolvedWrite {
                    seq: *seq,
                    tool: tool.clone(),
                })
            }
            _ => None,
        };
        let event = Event::RunAbandoned {
            reason,
            unresolved_write,
        };
        let envelope = EventEnvelope::new(run_id, state.next_seq, (self.clock)(), event);
        self.store.append(&envelope).await?;
        crate::progress::emit_step(run_id, envelope.seq, &envelope.event);
        Ok(run_id)
    }

    /// Reads a run's log, insisting it exists.
    async fn read_existing(&self, run_id: RunId) -> Result<Vec<EventEnvelope>, RuntimeError> {
        let log = self.store.read_log(run_id).await?;
        if log.is_empty() {
            return Err(RuntimeError::UnknownRun { run_id });
        }
        Ok(log)
    }

    /// Builds the per-run context with this runtime's hooks.
    fn ctx(&self, run_id: RunId, log: Vec<EventEnvelope>) -> Result<RunCtx, RuntimeError> {
        let mut ctx = RunCtx::with_hooks(
            self.store.clone(),
            run_id,
            log,
            self.clock.clone(),
            self.random.clone(),
        )?
        .with_record_prompts(self.record_prompts);
        if let Some(labels) = &self.labels {
            ctx = ctx.with_labels(labels.clone());
        }
        Ok(ctx)
    }
}

/// Attaches the run id to a loop outcome.
#[allow(clippy::unnecessary_wraps)]
fn finish(run_id: RunId, outcome: LoopOutcome) -> Result<RunOutcome, RuntimeError> {
    Ok(match outcome {
        LoopOutcome::Completed(output) => RunOutcome::Completed { run_id, output },
        LoopOutcome::Parked(reason) => RunOutcome::Parked { run_id, reason },
    })
}

/// A short status name for the not-parked error message.
fn status_name(status: &RunStatus) -> &'static str {
    match status {
        RunStatus::NotStarted => "not started",
        RunStatus::Running => "running",
        RunStatus::AwaitingModel => "awaiting model (interrupted; use recover)",
        RunStatus::AwaitingTool => "awaiting tool (interrupted; use recover)",
        RunStatus::Suspended { .. } => "suspended",
        RunStatus::BudgetExceeded { .. } => "budget exceeded",
        RunStatus::NeedsReconciliation => "needs reconciliation",
        RunStatus::Completed { .. } => "completed",
        RunStatus::Failed { .. } => "failed",
        RunStatus::Abandoned { .. } => "abandoned",
    }
}