Skip to main content

harness_loop_engine/
level.rs

1//! Loop maturity levels and the human-gate abstraction.
2//!
3//! Loop engineering's central safety idea: a loop earns autonomy in
4//! stages. You start **report-only**, graduate to **assisted** (the loop
5//! proposes, a human approves), and only the narrowest, well-fenced loops
6//! ever run **unattended**. Each level changes two things: whether the
7//! maker sub-agent may write at all, and how the gate decides what to do
8//! with a verified proposal.
9
10use serde::{Deserialize, Serialize};
11use std::sync::Arc;
12
13/// How much autonomy a loop is trusted with.
14///
15/// The level is the single knob that ties together write-capability and
16/// gate policy — it is *not* the same as a tool permission mode (that is
17/// `harness-permissions`, which the engine derives from the level).
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum LoopLevel {
21    /// **L1 — Report-only.** Discovery and visibility, no automated fixes.
22    /// The maker runs read-only; every proposal is escalated to a human as
23    /// a report. The safest place to start any loop.
24    L1Report,
25    /// **L2 — Assisted.** The maker may write inside an isolated sandbox,
26    /// but a human gates every change before it lands. The default for a
27    /// loop you are actively building trust in.
28    L2Assisted,
29    /// **L3 — Unattended.** Runs within strict guardrails: only actions on
30    /// the gate's allowlist commit automatically; anything else still
31    /// escalates. Reserve for narrow, well-understood loops.
32    L3Unattended,
33}
34
35impl LoopLevel {
36    /// Whether the maker sub-agent is permitted to mutate the workspace at
37    /// this level. L1 is strictly read-only.
38    pub fn maker_may_write(self) -> bool {
39        !matches!(self, LoopLevel::L1Report)
40    }
41
42    /// Short stable label for logs, reports, and memory entries.
43    pub fn label(self) -> &'static str {
44        match self {
45            LoopLevel::L1Report => "L1-report",
46            LoopLevel::L2Assisted => "L2-assisted",
47            LoopLevel::L3Unattended => "L3-unattended",
48        }
49    }
50}
51
52/// A change the maker produced and the checker verified, presented to the
53/// gate for a proceed-or-escalate decision.
54#[derive(Debug, Clone)]
55pub struct ProposedAction {
56    /// Stable kind used for allowlisting, e.g. `"commit"`, `"open-pr"`,
57    /// `"apply-patch"`, `"comment"`. Free-form; the gate matches on it.
58    pub kind: String,
59    /// Human-readable summary of what the loop wants to do.
60    pub summary: String,
61    /// Whether the checker reported the work as clean (tests/gates passed).
62    /// A gate may auto-proceed only on verified work.
63    pub verified: bool,
64}
65
66impl ProposedAction {
67    pub fn new(kind: impl Into<String>, summary: impl Into<String>, verified: bool) -> Self {
68        Self {
69            kind: kind.into(),
70            summary: summary.into(),
71            verified,
72        }
73    }
74}
75
76/// The gate's verdict for one proposed action.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum GateDecision {
79    /// Safe / allowlisted / verified — let the loop carry the action out.
80    AutoProceed,
81    /// Risky or ambiguous — hand it to a human with context. The loop
82    /// records the escalation and recurses on the next tick.
83    Escalate { reason: String },
84}
85
86impl GateDecision {
87    pub fn escalate(reason: impl Into<String>) -> Self {
88        GateDecision::Escalate {
89            reason: reason.into(),
90        }
91    }
92    pub fn is_auto(&self) -> bool {
93        matches!(self, GateDecision::AutoProceed)
94    }
95}
96
97/// Decides what happens to a verified proposal. Implementations encode the
98/// human-gate policy; the engine consults this once per round.
99pub trait HumanGate: Send + Sync {
100    fn decide(&self, level: LoopLevel, action: &ProposedAction) -> GateDecision;
101}
102
103/// Never auto-proceeds — every proposal is escalated. This is the correct
104/// gate for L1 loops (and a safe default for anything you're unsure about).
105pub struct AlwaysEscalate;
106
107impl HumanGate for AlwaysEscalate {
108    fn decide(&self, _level: LoopLevel, _action: &ProposedAction) -> GateDecision {
109        GateDecision::escalate("report-only / human review required")
110    }
111}
112
113/// Auto-proceeds only for verified actions whose `kind` is on the
114/// allowlist, and only at L3. Everything else escalates. This is the
115/// workhorse gate for unattended loops with a narrow blast radius.
116pub struct AllowlistGate {
117    allow_kinds: Vec<String>,
118}
119
120impl AllowlistGate {
121    pub fn new<I, S>(kinds: I) -> Self
122    where
123        I: IntoIterator<Item = S>,
124        S: Into<String>,
125    {
126        Self {
127            allow_kinds: kinds.into_iter().map(Into::into).collect(),
128        }
129    }
130}
131
132impl HumanGate for AllowlistGate {
133    fn decide(&self, level: LoopLevel, action: &ProposedAction) -> GateDecision {
134        if level != LoopLevel::L3Unattended {
135            return GateDecision::escalate("allowlist gate only auto-proceeds at L3");
136        }
137        if !action.verified {
138            return GateDecision::escalate("checker did not verify the work");
139        }
140        if self.allow_kinds.iter().any(|k| k == &action.kind) {
141            GateDecision::AutoProceed
142        } else {
143            GateDecision::escalate(format!("action kind `{}` is not allowlisted", action.kind))
144        }
145    }
146}
147
148/// Wraps an arbitrary closure as a gate — for custom policies (budget-aware,
149/// time-of-day, denylist, MCP-scope checks, …).
150pub struct CallbackGate<F>(pub F)
151where
152    F: Fn(LoopLevel, &ProposedAction) -> GateDecision + Send + Sync;
153
154impl<F> HumanGate for CallbackGate<F>
155where
156    F: Fn(LoopLevel, &ProposedAction) -> GateDecision + Send + Sync,
157{
158    fn decide(&self, level: LoopLevel, action: &ProposedAction) -> GateDecision {
159        (self.0)(level, action)
160    }
161}
162
163/// The gate a level implies when the caller doesn't specify one.
164///
165/// L1 and L2 both default to [`AlwaysEscalate`] — L1 because it only ever
166/// reports, L2 because a human must gate every change. L3 has no safe
167/// default (an unattended loop needs an explicit allowlist), so it also
168/// defaults to `AlwaysEscalate` until the caller supplies an
169/// [`AllowlistGate`] via `LoopEngine::with_gate`.
170pub fn default_gate_for(_level: LoopLevel) -> Arc<dyn HumanGate> {
171    Arc::new(AlwaysEscalate)
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn l1_is_read_only() {
180        assert!(!LoopLevel::L1Report.maker_may_write());
181        assert!(LoopLevel::L2Assisted.maker_may_write());
182        assert!(LoopLevel::L3Unattended.maker_may_write());
183    }
184
185    #[test]
186    fn always_escalate_never_proceeds() {
187        let g = AlwaysEscalate;
188        let a = ProposedAction::new("commit", "x", true);
189        assert!(!g.decide(LoopLevel::L3Unattended, &a).is_auto());
190    }
191
192    #[test]
193    fn allowlist_gate_only_auto_at_l3_for_verified_allowlisted() {
194        let g = AllowlistGate::new(["comment", "commit"]);
195        // L3 + verified + allowlisted -> auto
196        assert!(
197            g.decide(
198                LoopLevel::L3Unattended,
199                &ProposedAction::new("commit", "s", true)
200            )
201            .is_auto()
202        );
203        // not allowlisted -> escalate
204        assert!(
205            !g.decide(
206                LoopLevel::L3Unattended,
207                &ProposedAction::new("force-push", "s", true)
208            )
209            .is_auto()
210        );
211        // not verified -> escalate
212        assert!(
213            !g.decide(
214                LoopLevel::L3Unattended,
215                &ProposedAction::new("commit", "s", false)
216            )
217            .is_auto()
218        );
219        // wrong level -> escalate
220        assert!(
221            !g.decide(
222                LoopLevel::L2Assisted,
223                &ProposedAction::new("commit", "s", true)
224            )
225            .is_auto()
226        );
227    }
228
229    #[test]
230    fn callback_gate_runs_closure() {
231        let g = CallbackGate(|_lvl, a: &ProposedAction| {
232            if a.kind == "ok" {
233                GateDecision::AutoProceed
234            } else {
235                GateDecision::escalate("nope")
236            }
237        });
238        assert!(
239            g.decide(
240                LoopLevel::L3Unattended,
241                &ProposedAction::new("ok", "", true)
242            )
243            .is_auto()
244        );
245    }
246}