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}