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}