Skip to main content

agentplane/core/
skill.rs

1//! Skills — the only code an integrator writes.
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::core::{SkillError, Tainted};
8use crate::runtime::StepCtx;
9
10/// An abstract, domain-neutral capability name.
11///
12/// The engine compares these; it never interprets them. `de.mako.billing.audit`
13/// and `system.audit.invariant-checking` are equally meaningless to the runtime,
14/// which is what keeps it domain-agnostic.
15#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
16#[serde(transparent)]
17pub struct Capability(pub String);
18
19impl Capability {
20    pub fn new(s: impl Into<String>) -> Self {
21        Self(s.into())
22    }
23}
24
25impl std::fmt::Display for Capability {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        f.write_str(&self.0)
28    }
29}
30
31impl From<&str> for Capability {
32    fn from(s: &str) -> Self {
33        Self(s.to_owned())
34    }
35}
36
37/// What a skill is and what it promises.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct SkillDescriptor {
40    pub name: String,
41    /// The capabilities declared with [`provides`](Self::provides).
42    ///
43    /// Empty means **the skill's name is the capability** — see
44    /// [`capabilities`](Self::capabilities), which is what the runtime reads.
45    /// The name/capability split earns its keep in a plan graph, where one
46    /// skill answers an abstract capability another agent names; a hello-world
47    /// program should not have to invent two names for one thing.
48    pub provides: Vec<Capability>,
49}
50
51impl SkillDescriptor {
52    pub fn new(name: impl Into<String>) -> Self {
53        Self {
54            name: name.into(),
55            provides: Vec::new(),
56        }
57    }
58
59    #[must_use]
60    pub fn provides(mut self, cap: impl Into<Capability>) -> Self {
61        self.provides.push(cap.into());
62        self
63    }
64
65    /// The capabilities this skill answers: what it declared, or its own name.
66    ///
67    /// Never empty. Declaring a capability **replaces** the name default
68    /// rather than adding to it, so a skill that provides `demo.greet` is not
69    /// also silently reachable as `greet` — one declared surface, not two.
70    #[must_use]
71    pub fn capabilities(&self) -> Vec<Capability> {
72        if self.provides.is_empty() {
73            vec![Capability::new(&self.name)]
74        } else {
75            self.provides.clone()
76        }
77    }
78}
79
80/// What a step produced.
81#[derive(Debug)]
82#[non_exhaustive]
83pub enum Outcome {
84    // There is deliberately no `Delegate` variant, and its absence is the
85    // enum's one design decision worth stating: an outcome no build can
86    // service is a public API whose only reachable result is an error.
87    //
88    // Handing work to another agent is an **effect** — see the `blog_room`
89    // example. That is not a workaround but the better shape: an effect is
90    // journaled under a key, so a replay reads the sub-agent's answer back
91    // instead of commissioning the work a second time. An outcome variant
92    // would have reached another runtime outside the effect protocol, which is
93    // exactly the bypass the protocol exists to prevent.
94    /// Finished, with a labeled result.
95    Done(Tainted<Value>),
96    /// Recoverable fault; ask the planner for a new plan version.
97    Replan { reason: String },
98    /// Terminal failure.
99    Fail { reason: String },
100}
101
102impl Outcome {
103    pub fn done(v: Tainted<Value>) -> Self {
104        Self::Done(v)
105    }
106
107    pub fn fail(reason: impl Into<String>) -> Self {
108        Self::Fail {
109            reason: reason.into(),
110        }
111    }
112}
113
114/// A step's place in the saga, and what undoing it means.
115///
116/// A plan that touches real systems cannot be a transaction — there is nothing
117/// to roll back across a payment provider and a warehouse. The saga answer is
118/// to undo forward: when a later step fails, earlier steps are compensated in
119/// reverse order. Compensation is a *recovery design, not a time machine*, and
120/// what a step declares here is what the runtime is allowed to assume about it.
121#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123pub enum Compensation {
124    /// Nothing declared.
125    ///
126    /// **Resolved from evidence rather than assumed.** A step that performed no
127    /// mutating effect has nothing to undo, and the journal proves it — that
128    /// step passes. A step that *did* mutate and declared nothing is escalated,
129    /// because silently leaving a charge in place while unwinding everything
130    /// around it is the failure this whole mechanism exists to prevent.
131    ///
132    /// The default, so forgetting to declare is loud rather than convenient.
133    #[default]
134    Undeclared,
135
136    /// [`Skill::compensate`] undoes this step.
137    Compensatable,
138
139    /// The saga's point of no return.
140    ///
141    /// Once a pivot commits, the business is committed: nothing before it is
142    /// undone, because undoing it would reverse a decision the outside world
143    /// has already acted on. A failure after the pivot escalates rather than
144    /// unwinding.
145    Pivot,
146
147    /// Nothing to undo, and the author says so.
148    ///
149    /// Distinct from [`Undeclared`](Self::Undeclared): this is a claim someone
150    /// made, and it appears in the journal as one.
151    Unnecessary,
152}
153
154/// A stateless unit of logic.
155///
156/// Statelessness is not a style preference: it is what makes replay sound.
157/// All state lives in the journal, so re-running a skill against a replayed
158/// context reproduces its decisions exactly.
159///
160/// Note the absent variant: there is no `Outcome::SuspensionRequired`. Suspension
161/// is `cx.suspend().await`, so skill authors never hand-roll state
162/// serialization — the runtime persists the frame and drops the task.
163#[async_trait]
164pub trait Skill: Send + Sync + std::fmt::Debug + 'static {
165    fn descriptor(&self) -> SkillDescriptor;
166
167    async fn invoke(
168        &self,
169        cx: &mut StepCtx<'_>,
170        input: Tainted<Value>,
171    ) -> Result<Outcome, SkillError>;
172
173    /// This step's place in the saga.
174    ///
175    /// Defaults to [`Compensation::Undeclared`], which is resolved against the
176    /// journal rather than waved through — see that variant.
177    fn compensation(&self) -> Compensation {
178        Compensation::Undeclared
179    }
180
181    /// Undo this step, because a later one failed.
182    ///
183    /// Called in reverse order of completion, with the output this step
184    /// produced. The context is a full [`StepCtx`](crate::runtime::StepCtx):
185    /// compensating effects are journaled, retried, reconciled, and replayed
186    /// exactly like forward ones, because they are exposed to the same world
187    /// and can fail the same ways.
188    ///
189    /// Errors are not retried by unwinding further. A failed compensation is
190    /// not a problem more compensation solves, so the run is quarantined and an
191    /// operator is told which step could not be undone.
192    async fn compensate(
193        &self,
194        _cx: &mut StepCtx<'_>,
195        _output: &Tainted<Value>,
196    ) -> Result<(), SkillError> {
197        Err(SkillError::Other(
198            "this step declares Compensation::Compensatable but does not implement \
199             compensate()"
200                .into(),
201        ))
202    }
203}