Skip to main content

harness_loop/orchestrator/
route.rs

1//! Conditional edges and bounded cycles — what turns the DAG into a graph.
2//!
3//! A DAG says what may run in parallel. It cannot say *"if the review fails, go
4//! back and revise"*, and that shape is most of agent work: draft → review →
5//! revise → review again. Without it a caller either unrolls the loop by hand
6//! (three copies of the same job, and still a guess at how many) or drops out of
7//! the orchestrator entirely.
8//!
9//! One concept covers both gaps. A [`Router`] runs after a job succeeds and says
10//! what happens next: carry on, jump back to an earlier job, or stop the run.
11//! The `deps` graph stays acyclic — cycle detection at load time keeps working —
12//! and re-entry is a scheduling decision, made once per completion, with a hard
13//! visit cap so a loop that never converges ends as a dead letter rather than a
14//! bill.
15//!
16//! Put the criterion in the router, not in the prompt. A model asked to judge
17//! its own output against a rule tends to agree with itself — measured on a real
18//! run, one replied "LGTM" for a 30-character answer against a limit of 15, and
19//! the loop never ran. The same check as code is exact, and it costs no tokens.
20//!
21//! ```ignore
22//! let orch = Orchestrator::new(runner)
23//!     .route("review", |r: &JobResult| {
24//!         let n = r.text.chars().count();
25//!         if n <= LIMIT {
26//!             Next::Continue
27//!         } else {
28//!             Next::back_to_with("revise", format!("{n} characters, limit is {LIMIT}"))
29//!         }
30//!     })
31//!     .with_max_visits(4);
32//! ```
33
34use crate::orchestrator::job::{JobId, JobResult};
35
36/// What happens after a job succeeds.
37#[derive(Debug, Clone, PartialEq, Eq)]
38#[non_exhaustive]
39pub enum Next {
40    /// Ordinary progress: dependents run once their deps are satisfied. This is
41    /// what every job does without a router.
42    Continue,
43    /// Re-enter the graph at `job`, which runs again — along with everything
44    /// downstream of it that had already finished. The cycle.
45    ///
46    /// `feedback` is what the job is told on the way back in. Measured on a real
47    /// run without it: a `revise` job re-entered five times produced 33, 33, 33,
48    /// 31, 33 characters against a limit of 26 — it saw the same draft each lap
49    /// and no word of why it had been rejected, so it wrote the same thing. A
50    /// loop that cannot see its own last attempt repeats; it does not refine.
51    Goto {
52        job: JobId,
53        feedback: Option<String>,
54    },
55    /// End the run here and report success, leaving the rest of the graph
56    /// unrun. The early exit an iterative loop needs when it converges before
57    /// its budget: the answer is good enough, so stop paying for more.
58    Stop,
59}
60
61impl Next {
62    /// `Next::Goto`, named for how it reads at a call site: `back_to("revise")`.
63    ///
64    /// Prefer [`back_to_with`](Self::back_to_with) — a job told only "again"
65    /// tends to answer the same way.
66    pub fn back_to(job: impl Into<JobId>) -> Self {
67        Next::Goto {
68            job: job.into(),
69            feedback: None,
70        }
71    }
72
73    /// Go back, and say why. The `feedback` reaches the job as the first thing
74    /// it reads, above its own previous attempt.
75    pub fn back_to_with(job: impl Into<JobId>, feedback: impl Into<String>) -> Self {
76        Next::Goto {
77            job: job.into(),
78            feedback: Some(feedback.into()),
79        }
80    }
81}
82
83/// Decides [`Next`] from a finished job's result.
84///
85/// Implemented for any `Fn(&JobResult) -> Next`, so a closure is a router and
86/// nothing else has to be written for the common case.
87pub trait Router: Send + Sync {
88    fn route(&self, result: &JobResult) -> Next;
89}
90
91impl<F> Router for F
92where
93    F: Fn(&JobResult) -> Next + Send + Sync,
94{
95    fn route(&self, result: &JobResult) -> Next {
96        self(result)
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn a_closure_is_a_router() {
106        let r = |res: &JobResult| {
107            if res.text.contains("LGTM") {
108                Next::Continue
109            } else {
110                Next::back_to("revise")
111            }
112        };
113        assert_eq!(r.route(&JobResult::new("LGTM, ship it")), Next::Continue);
114        assert_eq!(
115            r.route(&JobResult::new("needs work")),
116            Next::back_to("revise")
117        );
118    }
119}