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}