Skip to main content

agent_effects/
effect.rs

1//! Describing an effect: the builder, what its action sees, and how it ends.
2
3use std::error::Error;
4use std::fmt;
5use std::future::Future;
6use std::pin::Pin;
7use std::sync::Arc;
8use std::time::Duration;
9
10use serde::Serialize;
11use serde::de::DeserializeOwned;
12use serde_json::Value;
13
14use crate::error::RuntimeError;
15use crate::failure::FailureClass;
16use crate::id::{EffectId, EffectKey, IdempotencyKey};
17use crate::kind::EffectKind;
18use crate::policy::{Capabilities, RiskLevel};
19use crate::retry::RetryPolicy;
20use crate::runtime::Runtime;
21use crate::store::{EffectStore, ErrorRecord};
22use crate::verification::{NoVerification, Verification, VerificationMode, Verifier, VerifyWith};
23
24/// What an effect's action knows about the attempt it is running.
25#[derive(Clone, Debug)]
26pub struct EffectContext {
27    pub(crate) id: EffectId,
28    pub(crate) key: EffectKey,
29    pub(crate) attempt: u32,
30}
31
32impl EffectContext {
33    /// The effect's record id.
34    pub fn effect_id(&self) -> EffectId {
35        self.id
36    }
37
38    /// The effect's logical identity.
39    pub fn key(&self) -> &EffectKey {
40        &self.key
41    }
42
43    /// The key to forward to the remote system, e.g. as an HTTP
44    /// `Idempotency-Key` header. The same for every attempt of the effect.
45    pub fn idempotency_key(&self) -> IdempotencyKey {
46        self.key.idempotency_key()
47    }
48
49    /// The attempt number, starting at 1.
50    pub fn attempt(&self) -> u32 {
51        self.attempt
52    }
53}
54
55/// A failed action, classified so the runtime knows whether it applied.
56///
57/// ```
58/// # use agent_effects::EffectFailure;
59/// // The connection was refused: nothing reached the remote system.
60/// let failure = EffectFailure::ambiguous("connection refused").request_sent(false);
61/// ```
62#[derive(Debug)]
63pub struct EffectFailure {
64    class: FailureClass,
65    request_sent: Option<bool>,
66    retry_after: Option<Duration>,
67    source: Box<dyn Error + Send + Sync>,
68}
69
70impl EffectFailure {
71    /// A failure of class `class`.
72    pub fn new(class: FailureClass, source: impl Into<Box<dyn Error + Send + Sync>>) -> Self {
73        Self {
74            class,
75            request_sent: None,
76            retry_after: None,
77            source: source.into(),
78        }
79    }
80
81    /// The request did not apply and may succeed if repeated.
82    pub fn transient(source: impl Into<Box<dyn Error + Send + Sync>>) -> Self {
83        Self::new(FailureClass::Transient, source)
84    }
85
86    /// The request did not apply and will not succeed if repeated.
87    pub fn permanent(source: impl Into<Box<dyn Error + Send + Sync>>) -> Self {
88        Self::new(FailureClass::Permanent, source)
89    }
90
91    /// The request may or may not have applied, e.g. a timeout after sending.
92    pub fn ambiguous(source: impl Into<Box<dyn Error + Send + Sync>>) -> Self {
93        Self::new(FailureClass::Ambiguous, source)
94    }
95
96    /// The remote system asked the caller to slow down.
97    pub fn rate_limited(
98        retry_after: Option<Duration>,
99        source: impl Into<Box<dyn Error + Send + Sync>>,
100    ) -> Self {
101        Self::new(FailureClass::RateLimited { retry_after }, source)
102    }
103
104    /// The credentials were missing or invalid.
105    pub fn authentication(source: impl Into<Box<dyn Error + Send + Sync>>) -> Self {
106        Self::new(FailureClass::Authentication, source)
107    }
108
109    /// The credentials were not allowed to perform the action.
110    pub fn authorization(source: impl Into<Box<dyn Error + Send + Sync>>) -> Self {
111        Self::new(FailureClass::Authorization, source)
112    }
113
114    /// The remote system rejected the request as malformed.
115    pub fn validation(source: impl Into<Box<dyn Error + Send + Sync>>) -> Self {
116        Self::new(FailureClass::Validation, source)
117    }
118
119    /// Records whether the request reached the remote system. `false` turns
120    /// an ambiguous failure into a transient one: nothing can have applied.
121    #[must_use]
122    pub fn request_sent(mut self, sent: bool) -> Self {
123        self.request_sent = Some(sent);
124        self
125    }
126
127    /// Adds a minimum retry delay without changing whether the operation
128    /// may have applied. This is scheduling advice, never evidence that a
129    /// retry is safe. The runtime persists it for ambiguous outcomes too.
130    #[must_use]
131    pub fn with_retry_after(mut self, delay: Duration) -> Self {
132        self.retry_after = Some(delay);
133        self
134    }
135
136    /// The minimum retry delay, independent of the failure's disposition.
137    pub fn retry_after(&self) -> Option<Duration> {
138        self.retry_after.max(self.class().retry_after())
139    }
140
141    /// The classification after taking [`Self::request_sent`] into account.
142    pub fn class(&self) -> FailureClass {
143        self.class.with_request_sent(self.request_sent)
144    }
145
146    pub(crate) fn to_record(&self) -> ErrorRecord {
147        ErrorRecord {
148            class: Some(self.class()),
149            message: self.source.to_string(),
150        }
151    }
152}
153
154impl fmt::Display for EffectFailure {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        write!(f, "{:?} failure: {}", self.class(), self.source)
157    }
158}
159
160impl Error for EffectFailure {
161    fn source(&self) -> Option<&(dyn Error + 'static)> {
162        Some(self.source.as_ref())
163    }
164}
165
166/// How an effect ended, or where it stands.
167///
168/// `Unknown` and `NeedsIntervention` are results, not errors: the effect may
169/// have changed the outside world, and the caller must not treat it as
170/// failed.
171#[derive(Debug, Clone, PartialEq)]
172#[non_exhaustive]
173pub enum EffectOutcome<T> {
174    /// The effect applied. Carries the action's output, from this call or
175    /// from the record of an earlier one.
176    Committed(T),
177    /// The effect definitely did not apply and will not be retried.
178    Failed(ErrorRecord),
179    /// The effect was refused before it ran.
180    Rejected(ErrorRecord),
181    /// The effect may or may not have applied. Calling again with the same
182    /// key resolves it if that is safe.
183    Unknown {
184        /// The effect.
185        id: EffectId,
186    },
187    /// The runtime cannot resolve the outcome safely; an operator must.
188    NeedsIntervention {
189        /// The effect.
190        id: EffectId,
191    },
192    /// Another caller or worker is running the effect right now.
193    InProgress {
194        /// The effect.
195        id: EffectId,
196    },
197    /// The effect waits for a human decision. Call again later, or have an
198    /// operator use [`Runtime::approve`](crate::Runtime::approve) /
199    /// [`Runtime::deny`](crate::Runtime::deny).
200    AwaitingApproval {
201        /// The effect.
202        id: EffectId,
203    },
204    /// The effect applied and was later undone by compensation.
205    Compensated {
206        /// The effect.
207        id: EffectId,
208    },
209}
210
211/// Whether an effect may still run, checked before its first attempt.
212#[derive(Clone, Debug, PartialEq, Eq)]
213pub enum Precondition {
214    /// Go ahead.
215    Satisfied,
216    /// Never run this effect, e.g. "order is no longer pending". The effect
217    /// ends [`EffectOutcome::Rejected`].
218    Rejected {
219        /// Why, recorded on the effect.
220        reason: String,
221    },
222    /// Not yet; check again after `after`, e.g. while a dependency settles.
223    /// After as many checks as the retry policy allows attempts, the effect
224    /// is rejected.
225    RetryLater {
226        /// How long to wait before the next check.
227        after: Duration,
228        /// Why, recorded if the effect is eventually rejected.
229        reason: String,
230    },
231}
232
233impl Precondition {
234    /// A rejection with `reason`.
235    pub fn reject(reason: impl Into<String>) -> Self {
236        Self::Rejected {
237            reason: reason.into(),
238        }
239    }
240
241    /// A deferral of `after`, with `reason`.
242    pub fn retry_later(after: Duration, reason: impl Into<String>) -> Self {
243        Self::RetryLater {
244            after,
245            reason: reason.into(),
246        }
247    }
248}
249
250pub(crate) type PreconditionFn =
251    Arc<dyn Fn(EffectContext) -> Pin<Box<dyn Future<Output = Precondition> + Send>> + Send + Sync>;
252
253/// Builds and runs one effect. Created by [`Runtime::effect`].
254///
255/// Invalid names, keys or inputs are reported when the effect is run, so the
256/// whole chain needs a single `?`.
257#[must_use = "an effect does nothing until `run` is awaited"]
258pub struct EffectBuilder<S, V = NoVerification> {
259    runtime: Runtime<S>,
260    name: String,
261    key: String,
262    kind: EffectKind,
263    remote_idempotency: bool,
264    input: Option<Result<Value, serde_json::Error>>,
265    actor: Option<String>,
266    retry: RetryPolicy,
267    attempt_timeout: Option<Duration>,
268    precondition: Option<PreconditionFn>,
269    require_approval: bool,
270    risk: RiskLevel,
271    verification: VerificationMode,
272    verifier: V,
273    handler_version: String,
274    adapter: Option<String>,
275    input_schema_version: u32,
276    output_schema: crate::PayloadSchema,
277}
278
279impl<S: EffectStore> EffectBuilder<S> {
280    pub(crate) fn new(runtime: Runtime<S>, name: String, key: String) -> Self {
281        let retry = runtime.default_retry();
282        Self {
283            runtime,
284            name,
285            key,
286            kind: EffectKind::IrreversibleWrite,
287            remote_idempotency: false,
288            input: None,
289            actor: None,
290            retry,
291            attempt_timeout: None,
292            precondition: None,
293            require_approval: false,
294            risk: RiskLevel::Low,
295            verification: VerificationMode::None,
296            verifier: NoVerification,
297            handler_version: "1".into(),
298            adapter: None,
299            input_schema_version: 1,
300            output_schema: crate::PayloadSchema::default(),
301        }
302    }
303
304    /// Verifies the effect against a remote system that reads its own
305    /// writes: [`Verification::NotApplied`] is trusted at once.
306    ///
307    /// The check runs after every successful attempt (a postcondition) and
308    /// to reconcile an attempt whose outcome is unknown. It makes re-running
309    /// safe even for irreversible effects, because the runtime only re-runs
310    /// an effect the remote system says did not apply.
311    pub fn verify<T, F, Fut>(self, check: F) -> EffectBuilder<S, VerifyWith<F>>
312    where
313        F: Fn(EffectContext) -> Fut + Send + Sync + 'static,
314        Fut: Future<Output = Result<Verification<T>, EffectFailure>> + Send + 'static,
315    {
316        self.with_verifier(VerificationMode::Authoritative, VerifyWith(check))
317    }
318
319    /// Like [`Self::verify`], for a remote lookup that lags behind its writes
320    /// by up to `settle`. [`Verification::NotApplied`] is trusted only once
321    /// `settle` has passed since the attempt ended; until then the runtime
322    /// waits and checks again.
323    pub fn verify_eventually<T, F, Fut>(
324        self,
325        settle: Duration,
326        check: F,
327    ) -> EffectBuilder<S, VerifyWith<F>>
328    where
329        F: Fn(EffectContext) -> Fut + Send + Sync + 'static,
330        Fut: Future<Output = Result<Verification<T>, EffectFailure>> + Send + 'static,
331    {
332        self.with_verifier(
333            VerificationMode::EventuallyConsistent { settle },
334            VerifyWith(check),
335        )
336    }
337
338    fn with_verifier<V>(self, mode: VerificationMode, verifier: V) -> EffectBuilder<S, V> {
339        EffectBuilder {
340            runtime: self.runtime,
341            name: self.name,
342            key: self.key,
343            kind: self.kind,
344            remote_idempotency: self.remote_idempotency,
345            input: self.input,
346            actor: self.actor,
347            retry: self.retry,
348            attempt_timeout: self.attempt_timeout,
349            precondition: self.precondition,
350            require_approval: self.require_approval,
351            risk: self.risk,
352            verification: mode,
353            verifier,
354            handler_version: self.handler_version,
355            adapter: self.adapter,
356            input_schema_version: self.input_schema_version,
357            output_schema: self.output_schema,
358        }
359    }
360}
361
362impl<S: EffectStore, V> EffectBuilder<S, V> {
363    /// Versions the stored input (default 1). Reattachment must use the
364    /// recorded input schema; durable input migrations belong on handlers.
365    pub fn input_schema_version(mut self, version: u32) -> Self {
366        self.input_schema_version = version;
367        self
368    }
369
370    /// Versions new outputs and optionally migrates older outputs on replay.
371    /// A replay error leaves a committed effect committed and never re-runs it.
372    pub fn output_schema(mut self, schema: crate::PayloadSchema) -> Self {
373        self.output_schema = schema;
374        self
375    }
376
377    /// Names the application's execution semantics (default `"1"`). Changing
378    /// it for an existing logical key returns a contract mismatch. Retain
379    /// it across releases only when execution assumptions stay compatible.
380    pub fn contract_version(mut self, version: impl Into<String>) -> Self {
381        self.handler_version = version.into();
382        self
383    }
384
385    /// Identifies a provider or adapter whose safety guarantees the effect
386    /// relies on. A change is incompatible for an existing logical key.
387    /// Keep credentials and tenant secrets out of this value.
388    pub fn adapter_identity(mut self, adapter: impl Into<String>) -> Self {
389        self.adapter = Some(adapter.into());
390        self
391    }
392
393    /// The effect's kind. Defaults to [`EffectKind::IrreversibleWrite`], the
394    /// most cautious choice.
395    pub fn kind(mut self, kind: EffectKind) -> Self {
396        self.kind = kind;
397        self
398    }
399
400    /// Declares that the remote system deduplicates on
401    /// [`EffectContext::idempotency_key`], which the action must send. This
402    /// makes re-running after an unknown outcome safe.
403    pub fn remote_idempotency(mut self, supported: bool) -> Self {
404        self.remote_idempotency = supported;
405        self
406    }
407
408    /// The input the action acts on. It is stored for audit and fingerprinted:
409    /// reusing the key with a different input fails with
410    /// [`RuntimeError::InputMismatch`].
411    ///
412    /// The input is persisted as-is. Keep credentials in the action's
413    /// captured state, not in the input.
414    pub fn input<I: Serialize + ?Sized>(mut self, input: &I) -> Self {
415        self.input = Some(serde_json::to_value(input));
416        self
417    }
418
419    /// Who is asking for the effect, e.g. `agent:refund-agent`. Recorded on
420    /// the effect and its audit events.
421    pub fn actor(mut self, actor: impl Into<String>) -> Self {
422        self.actor = Some(actor.into());
423        self
424    }
425
426    /// The retry policy. Defaults to the runtime's
427    /// ([`RuntimeBuilder::retry_policy`](crate::RuntimeBuilder::retry_policy)).
428    ///
429    /// `max_attempts` bounds the attempts over the effect's whole life,
430    /// across calls and restarts. It also bounds verification and
431    /// precondition checks per call.
432    pub fn retry(mut self, policy: RetryPolicy) -> Self {
433        self.retry = policy;
434        self
435    }
436
437    /// Gives up waiting for an attempt after `timeout`. The request may have
438    /// been sent, so a timeout is an ambiguous failure.
439    pub fn attempt_timeout(mut self, timeout: Duration) -> Self {
440        self.attempt_timeout = Some(timeout);
441        self
442    }
443
444    /// A check that must pass before the effect's first attempt, so a stale
445    /// decision is not carried out, e.g. "refund only if the order is still
446    /// unrefunded".
447    ///
448    /// It runs only before the first attempt. Once an attempt may have
449    /// applied, the effect's own success could falsify the check: a refund
450    /// that went through makes "not yet refunded" false.
451    pub fn precondition<F, Fut>(mut self, check: F) -> Self
452    where
453        F: Fn(EffectContext) -> Fut + Send + Sync + 'static,
454        Fut: Future<Output = Precondition> + Send + 'static,
455    {
456        self.precondition = Some(Arc::new(move |ctx| Box::pin(check(ctx))));
457        self
458    }
459
460    /// How much damage the effect could do; the runtime's
461    /// [`RiskPolicy`](crate::RiskPolicy) adds requirements by risk. Defaults
462    /// to [`RiskLevel::Low`].
463    pub fn risk(mut self, risk: RiskLevel) -> Self {
464        self.risk = risk;
465        self
466    }
467
468    /// Requires a human decision before the first attempt; see
469    /// [`approval`](crate::approval). The effect waits in
470    /// `AwaitingApproval`, durably, until the runtime's approval provider or
471    /// an operator decides.
472    pub fn require_approval(mut self) -> Self {
473        self.require_approval = true;
474        self
475    }
476
477    /// Runs the effect, or attaches to an earlier run with the same key.
478    ///
479    /// The action may be called more than once over the effect's life (for
480    /// example to re-run an idempotent effect whose outcome was unknown), so
481    /// it is an `Fn`. It runs on a spawned task: dropping the returned future
482    /// does not abort an attempt that has started, and its result is still
483    /// recorded.
484    ///
485    /// # Errors
486    ///
487    /// Infrastructure failures only; see [`RuntimeError`]. Every effect
488    /// result, including an unknown one, is an [`EffectOutcome`].
489    pub async fn run<T, F, Fut>(self, action: F) -> Result<EffectOutcome<T>, RuntimeError>
490    where
491        T: Serialize + DeserializeOwned + Send + 'static,
492        F: Fn(EffectContext) -> Fut + Send + Sync + 'static,
493        Fut: Future<Output = Result<T, EffectFailure>> + Send + 'static,
494        V: Verifier<T>,
495    {
496        let key = EffectKey::new(
497            crate::id::EffectName::new(self.name)?,
498            crate::id::LogicalKey::new(self.key)?,
499        );
500        let input = self.input.transpose().map_err(RuntimeError::Input)?;
501        let spec = EffectSpec {
502            // Computed by the runtime, after redaction.
503            fingerprint: None,
504            input,
505            key,
506            capabilities: Capabilities {
507                kind: self.kind,
508                remote_idempotency: self.remote_idempotency,
509                verification: self.verification,
510            },
511            actor: self.actor,
512            retry: self.retry,
513            attempt_timeout: self.attempt_timeout,
514            precondition: self.precondition,
515            require_approval: self.require_approval,
516            risk: self.risk,
517            automatic_retry: true,
518            input_stored: false,
519            handler_version: self.handler_version,
520            adapter: self.adapter,
521            input_schema_version: self.input_schema_version,
522            output_schema: self.output_schema,
523        };
524        self.runtime.execute(spec, action, self.verifier).await
525    }
526}
527
528/// Everything about an effect except its action and verifier.
529pub(crate) struct EffectSpec {
530    pub(crate) input_schema_version: u32,
531    pub(crate) output_schema: crate::PayloadSchema,
532    pub(crate) handler_version: String,
533    pub(crate) adapter: Option<String>,
534    pub(crate) key: EffectKey,
535    pub(crate) capabilities: Capabilities,
536    pub(crate) input: Option<Value>,
537    pub(crate) fingerprint: Option<String>,
538    pub(crate) actor: Option<String>,
539    pub(crate) retry: RetryPolicy,
540    pub(crate) attempt_timeout: Option<Duration>,
541    pub(crate) precondition: Option<PreconditionFn>,
542    pub(crate) require_approval: bool,
543    pub(crate) risk: RiskLevel,
544    /// Whether the runtime may retry or re-run on its own; a risk policy
545    /// can turn it off.
546    pub(crate) automatic_retry: bool,
547    /// The input comes from the store (a resumed effect): it is already
548    /// redacted, and `fingerprint` is the stored one, used as is.
549    pub(crate) input_stored: bool,
550}
551
552impl EffectSpec {
553    pub(crate) fn contract(&self) -> crate::store::ExecutionContract {
554        use crate::store::{ExecutionContract, VerificationContract};
555        ExecutionContract {
556            format_version: 1,
557            kind: self.capabilities.kind,
558            remote_idempotency: self.capabilities.remote_idempotency,
559            verification: match self.capabilities.verification {
560                VerificationMode::None => VerificationContract::None,
561                VerificationMode::Authoritative => VerificationContract::Authoritative,
562                VerificationMode::EventuallyConsistent { settle } => {
563                    VerificationContract::EventuallyConsistent { settle }
564                }
565            },
566            handler_version: self.handler_version.clone(),
567            adapter: self.adapter.clone(),
568            require_approval: self.require_approval,
569            automatic_retry: self.automatic_retry,
570            max_attempts: self.retry.max_attempts,
571        }
572    }
573}