Skip to main content

heartbit_core/browser/
plan.rs

1//! Plan artifact + replan decision logic (capability 8 of the browser-bot spec).
2//!
3//! Deep-research convergence (Manus, Plan-and-Act, Online-Mind2Web/WebJudge) is
4//! unanimous: for an a11y-grounded web agent, *grounding is not the gap* —
5//! long-horizon **planning and recovery** is. The single biggest lever over a
6//! bare ReAct loop is an explicit plan with **replanning on failure**: decompose
7//! the task into ordered subgoals, execute one at a time, verify each, and when a
8//! step stalls, revise the plan instead of blindly retrying the same action.
9//!
10//! Two hard-won design rules from the research are baked in here:
11//!
12//! 1. **The plan is a harness-owned artifact, not something the executor
13//!    rewrites every turn.** Manus found an agent that rewrites its whole
14//!    `todo.md` each step burned ~1/3 of all actions on bookkeeping. So [`Plan`]
15//!    is rendered deterministically by the harness ([`Plan::render`]) and the
16//!    model only *flips a step's status*; it never re-emits the list to make
17//!    progress.
18//! 2. **The replan trigger is a pure decision over the verified step outcome.**
19//!    [`replan_decision`] is a clock-free, LLM-free state machine mapping
20//!    `(StepOutcome, attempts, budget)` to a [`ReplanAction`] — so every branch
21//!    (advance / retry / replan / give up / done) is exhaustively unit-testable,
22//!    mirroring [`super::settle::step`] and [`super::verify::diff`]. The LLM
23//!    planner/executor is a thin async shell layered on top (built later, like
24//!    settle's driver), keeping the load-bearing logic deterministic.
25
26use std::fmt::Write as _;
27
28/// Lifecycle of a single plan step.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum StepStatus {
31    /// Not started.
32    Pending,
33    /// Currently executing.
34    Active,
35    /// Verified complete.
36    Done,
37    /// Abandoned after exhausting retries/replans (recorded, not silently
38    /// dropped — keeping failures visible is itself a research-backed tactic).
39    Failed,
40}
41
42impl StepStatus {
43    /// The checkbox-style glyph used in [`Plan::render`].
44    fn glyph(self) -> char {
45        match self {
46            StepStatus::Pending => ' ',
47            StepStatus::Active => '>',
48            StepStatus::Done => 'x',
49            StepStatus::Failed => '!',
50        }
51    }
52}
53
54/// One subgoal in a [`Plan`].
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct PlanStep {
57    /// Human/LLM-readable subgoal description.
58    pub description: String,
59    /// Current lifecycle status.
60    pub status: StepStatus,
61}
62
63impl PlanStep {
64    /// A fresh `Pending` step.
65    pub fn new(description: impl Into<String>) -> Self {
66        Self {
67            description: description.into(),
68            status: StepStatus::Pending,
69        }
70    }
71}
72
73/// An ordered, harness-owned task plan: a top-level goal decomposed into steps.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct Plan {
76    /// The overall task this plan accomplishes (re-surfaced each turn to fight
77    /// goal-drift / "lost in the middle" on long runs).
78    pub goal: String,
79    /// Ordered subgoals.
80    pub steps: Vec<PlanStep>,
81}
82
83impl Plan {
84    /// Build a plan from a goal and an ordered list of step descriptions.
85    pub fn new(
86        goal: impl Into<String>,
87        steps: impl IntoIterator<Item = impl Into<String>>,
88    ) -> Self {
89        Self {
90            goal: goal.into(),
91            steps: steps.into_iter().map(PlanStep::new).collect(),
92        }
93    }
94
95    /// Index of the first non-terminal (`Pending`/`Active`) step — the one the
96    /// executor should be working on. `None` when every step is `Done`/`Failed`.
97    pub fn current(&self) -> Option<usize> {
98        self.steps
99            .iter()
100            .position(|s| matches!(s.status, StepStatus::Pending | StepStatus::Active))
101    }
102
103    /// Whether every step reached a terminal status (`Done` or `Failed`).
104    pub fn is_complete(&self) -> bool {
105        self.current().is_none()
106    }
107
108    /// Whether all steps are specifically `Done` (the success condition).
109    pub fn all_done(&self) -> bool {
110        !self.steps.is_empty() && self.steps.iter().all(|s| s.status == StepStatus::Done)
111    }
112
113    /// Mark the step at `idx`, returning `false` if `idx` is out of range (no
114    /// panic — library code must not index blindly).
115    pub fn set_status(&mut self, idx: usize, status: StepStatus) -> bool {
116        match self.steps.get_mut(idx) {
117            Some(step) => {
118                step.status = status;
119                true
120            }
121            None => false,
122        }
123    }
124
125    /// Deterministic, cache-stable rendering for re-injection into the prompt
126    /// near the context tail (goal recitation). The harness owns this; the model
127    /// reads it and only requests status flips — it never reproduces the list to
128    /// make progress.
129    pub fn render(&self) -> String {
130        let mut out = String::with_capacity(self.goal.len() + self.steps.len() * 32);
131        let _ = writeln!(out, "Goal: {}", self.goal);
132        for (i, step) in self.steps.iter().enumerate() {
133            let _ = writeln!(
134                out,
135                "{}. [{}] {}",
136                i + 1,
137                step.status.glyph(),
138                step.description
139            );
140        }
141        // Trim the final newline so the artifact concatenates predictably.
142        if out.ends_with('\n') {
143            out.pop();
144        }
145        out
146    }
147}
148
149/// The verified outcome of executing the current step (the input signal to
150/// [`replan_decision`]). Derived by the harness from the executor's
151/// [`AgentOutput`](crate::AgentOutput) plus a [`super::verify`] state-diff.
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum StepOutcome {
154    /// The step's subgoal was verified achieved.
155    Succeeded,
156    /// The action ran but produced no progress (e.g. a verified
157    /// [`super::verify::ActionEffect::NoOp`]) or a recoverable error — worth a
158    /// bounded retry of the *same* step.
159    Stalled,
160    /// A hard failure that retrying the same step won't fix (the page diverged
161    /// from the plan's assumptions) — the plan itself needs revising.
162    Diverged,
163}
164
165/// What the loop should do next, decided purely from the step outcome and the
166/// retry/replan budget consumed so far.
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum ReplanAction {
169    /// Step succeeded: mark it `Done` and move to the next step.
170    Advance,
171    /// Retry the *same* step (a bounded number of times) before escalating.
172    Retry,
173    /// Ask the planner to revise the remaining steps (the page diverged or the
174    /// step exhausted its retries).
175    Replan,
176    /// Stop: the whole task succeeded (no steps remain).
177    Done,
178    /// Stop: out of retry/replan budget — give up with the plan as-is.
179    GiveUp,
180}
181
182/// Bounds on recovery effort, so a stuck agent can't loop forever.
183#[derive(Debug, Clone, Copy)]
184pub struct ReplanBudget {
185    /// Max retries of a single step before it escalates to a replan.
186    pub max_step_retries: u32,
187    /// Max whole-plan revisions before giving up.
188    pub max_replans: u32,
189}
190
191impl Default for ReplanBudget {
192    fn default() -> Self {
193        Self {
194            max_step_retries: 2,
195            max_replans: 3,
196        }
197    }
198}
199
200/// Pure replan decision: given the verified `outcome` of the current step, how
201/// many times that step has already been retried (`step_attempts`, not counting
202/// the first try), how many whole-plan replans have happened (`replans_used`),
203/// and whether any non-terminal step remains (`has_current`), decide the next
204/// [`ReplanAction`].
205///
206/// Branch order is significant and exhaustively tested:
207/// - `Succeeded` + no step remaining → [`ReplanAction::Done`].
208/// - `Succeeded` + a step remaining → [`ReplanAction::Advance`].
209/// - `Stalled` + retries left → [`ReplanAction::Retry`]; else fall through to
210///   the replan path (a step that won't progress is a planning problem).
211/// - `Diverged`, or a stall that exhausted step retries → [`ReplanAction::Replan`]
212///   while replan budget remains, else [`ReplanAction::GiveUp`].
213pub fn replan_decision(
214    budget: &ReplanBudget,
215    outcome: StepOutcome,
216    step_attempts: u32,
217    replans_used: u32,
218    has_current: bool,
219) -> ReplanAction {
220    match outcome {
221        StepOutcome::Succeeded => {
222            if has_current {
223                ReplanAction::Advance
224            } else {
225                ReplanAction::Done
226            }
227        }
228        StepOutcome::Stalled if step_attempts < budget.max_step_retries => ReplanAction::Retry,
229        // Diverged, or stalled past the per-step retry ceiling: revise the plan
230        // if we still can, otherwise give up rather than spin.
231        StepOutcome::Stalled | StepOutcome::Diverged => {
232            if replans_used < budget.max_replans {
233                ReplanAction::Replan
234            } else {
235                ReplanAction::GiveUp
236            }
237        }
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    // --- Plan artifact ---
246
247    #[test]
248    fn current_points_at_first_unfinished_step() {
249        let mut p = Plan::new("book a flight", ["search", "select", "pay"]);
250        assert_eq!(p.current(), Some(0));
251        assert!(p.set_status(0, StepStatus::Done));
252        assert_eq!(p.current(), Some(1), "advances past the Done step");
253        assert!(p.set_status(1, StepStatus::Active));
254        assert_eq!(p.current(), Some(1), "an Active step is still 'current'");
255    }
256
257    #[test]
258    fn is_complete_and_all_done_distinguish_failure() {
259        let mut p = Plan::new("task", ["a", "b"]);
260        assert!(!p.is_complete());
261        assert!(!p.all_done());
262        p.set_status(0, StepStatus::Done);
263        p.set_status(1, StepStatus::Failed);
264        assert!(p.is_complete(), "all steps terminal → complete");
265        assert!(!p.all_done(), "a Failed step means not all_done");
266    }
267
268    #[test]
269    fn all_done_is_false_for_empty_plan() {
270        let p = Plan::new("nothing", Vec::<String>::new());
271        assert!(!p.all_done(), "an empty plan has not accomplished anything");
272        assert!(p.is_complete(), "but it has no pending work");
273    }
274
275    #[test]
276    fn set_status_out_of_range_is_false_not_panic() {
277        let mut p = Plan::new("task", ["only"]);
278        assert!(!p.set_status(9, StepStatus::Done));
279        assert_eq!(p.steps[0].status, StepStatus::Pending, "unchanged");
280    }
281
282    #[test]
283    fn render_is_deterministic_and_shows_status_glyphs() {
284        let mut p = Plan::new("buy milk", ["open store", "add to cart", "checkout"]);
285        p.set_status(0, StepStatus::Done);
286        p.set_status(1, StepStatus::Active);
287        let r = p.render();
288        assert_eq!(
289            r,
290            "Goal: buy milk\n1. [x] open store\n2. [>] add to cart\n3. [ ] checkout"
291        );
292        // Cache-stability: rendering twice yields byte-identical output.
293        assert_eq!(p.render(), r);
294    }
295
296    // --- pure replan_decision state machine ---
297
298    fn bud() -> ReplanBudget {
299        ReplanBudget {
300            max_step_retries: 2,
301            max_replans: 3,
302        }
303    }
304
305    #[test]
306    fn success_with_steps_remaining_advances() {
307        assert_eq!(
308            replan_decision(&bud(), StepOutcome::Succeeded, 0, 0, true),
309            ReplanAction::Advance
310        );
311    }
312
313    #[test]
314    fn success_with_no_steps_remaining_is_done() {
315        assert_eq!(
316            replan_decision(&bud(), StepOutcome::Succeeded, 0, 0, false),
317            ReplanAction::Done
318        );
319    }
320
321    #[test]
322    fn stall_retries_until_ceiling_then_replans() {
323        // attempts 0 and 1 (< max_step_retries=2) → Retry.
324        assert_eq!(
325            replan_decision(&bud(), StepOutcome::Stalled, 0, 0, true),
326            ReplanAction::Retry
327        );
328        assert_eq!(
329            replan_decision(&bud(), StepOutcome::Stalled, 1, 0, true),
330            ReplanAction::Retry
331        );
332        // attempt 2 (== ceiling) → escalate to Replan (retrying won't help).
333        assert_eq!(
334            replan_decision(&bud(), StepOutcome::Stalled, 2, 0, true),
335            ReplanAction::Replan
336        );
337    }
338
339    #[test]
340    fn diverged_replans_immediately_without_retrying() {
341        // A hard divergence skips per-step retries entirely.
342        assert_eq!(
343            replan_decision(&bud(), StepOutcome::Diverged, 0, 0, true),
344            ReplanAction::Replan
345        );
346    }
347
348    #[test]
349    fn replan_budget_exhaustion_gives_up() {
350        // replans_used == max_replans=3 → no revisions left → GiveUp.
351        assert_eq!(
352            replan_decision(&bud(), StepOutcome::Diverged, 0, 3, true),
353            ReplanAction::GiveUp
354        );
355        assert_eq!(
356            replan_decision(&bud(), StepOutcome::Stalled, 5, 3, true),
357            ReplanAction::GiveUp
358        );
359    }
360
361    #[test]
362    fn zero_retry_budget_escalates_on_first_stall() {
363        let b = ReplanBudget {
364            max_step_retries: 0,
365            max_replans: 1,
366        };
367        // No retries allowed → first stall goes straight to Replan.
368        assert_eq!(
369            replan_decision(&b, StepOutcome::Stalled, 0, 0, true),
370            ReplanAction::Replan
371        );
372    }
373}