Skip to main content

agentd/runtime/
goal.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The self-correcting **goal watchdog**. A supervisor-level periodic
3//! check — it never blocks the agent loop — of whether the configured `goal` is
4//! achieved, or the agent is **stuck** (no progress across `stuck_after` checks):
5//!
6//!   * achievement is judged **CEL-condition first** (a cheap deterministic
7//!     predicate over the live status), then an **LLM judge** for fuzzy goals
8//!     (`check.via: agent|both`) — run asynchronously on an executor thread and
9//!     folded back via [`Event::Background`], so it never blocks the loop;
10//!   * **progress** is a monotonic activity signal (finished runs + turns); when
11//!     it stalls for `stuck_after` cycles the watchdog **self-corrects**;
12//!   * dispositions: `on_achieved` → `finish` (drain, exit 0) / `idle` (stop
13//!     checking) / `{workflow}` (run it); `on_stuck` → `{workflow}` (a recovery /
14//!     re-plan workflow — the concrete self-correction) / `replan` / `escalate` /
15//!     `idle` / `finish`.
16//!
17//! The check runs on a durable `{"kind":"goal"}` timer, so its cadence survives a
18//! restart.
19
20use serde_json::{Value, json};
21
22use crate::config::v2::{Goal, GoalAction};
23use crate::intel::client::IntelClient;
24use crate::runtime::events::Event;
25use crate::state::{Kind, now_ms};
26use crate::wire::intel::{Message, Request};
27
28/// The default check cadence when `goal.check.every` is unset.
29const DEFAULT_EVERY_MS: u64 = 300_000; // 5m
30/// The durable key holding the watchdog's cross-check state.
31const GOAL_STATE: &str = "_goal/state";
32/// A judge older than this (ms) is treated as lost, so checks resume.
33const JUDGE_STALE_MS: u64 = 120_000;
34
35impl crate::runtime::reactor::Runtime {
36    /// Arm the goal watchdog at startup/restore (a no-op without `goal`). Idempotent:
37    /// a restart re-arms from the durable cadence.
38    pub(crate) fn arm_goal(&mut self) {
39        let Some(g) = self.settings.goal.clone() else {
40            return;
41        };
42        let every = goal_every_ms(&g);
43        let _ = self.timers.arm(
44            &self.durable,
45            now_ms() + every,
46            json!({"kind": "goal"}),
47            json!({}),
48        );
49        self.log.info(
50            "goal.armed",
51            json!({"every_ms": every, "statement": g.statement, "via": g.check.via.as_deref().unwrap_or("both"), "stuck_after": g.stuck_after.unwrap_or(3)}),
52        );
53    }
54
55    /// A goal-check timer fired: evaluate achievement + progress, run the LLM judge
56    /// if configured, and dispatch. Always re-arms the next cadence (a daemon
57    /// keeps watching); `finish`/`idle` stop it via the drain / a parked note.
58    pub(crate) fn on_goal_check(&mut self, _payload: &Value) {
59        let Some(g) = self.settings.goal.clone() else {
60            return;
61        };
62        let state = self.status_value();
63
64        // 1. Achievement — the CEL condition (deterministic) decides first.
65        let mut achieved = false;
66        if let Some(cond) = &g.check.condition {
67            let expr = cond.trim().trim_start_matches("CEL:").trim();
68            achieved = crate::cel::eval_bool(expr, &[("state", &state)]).unwrap_or(false);
69        }
70
71        // 2. Progress / stuck — a monotonic activity signal (finished runs + turns).
72        let progress = state["counters"]["runs_finished"].as_u64().unwrap_or(0)
73            + state["counters"]["turns"].as_u64().unwrap_or(0);
74        let prev = self
75            .durable
76            .get(Kind::Memory, GOAL_STATE)
77            .ok()
78            .flatten()
79            .map(|e| e.state)
80            .unwrap_or_else(|| json!({"no_progress": 0, "last_progress": 0}));
81        let last = prev["last_progress"].as_u64().unwrap_or(0);
82        let mut no_progress = prev["no_progress"].as_u64().unwrap_or(0);
83        if achieved || progress > last {
84            no_progress = 0;
85        } else {
86            no_progress += 1;
87        }
88        let stuck_after = g.stuck_after.unwrap_or(3) as u64;
89        let stuck_det = !achieved && no_progress >= stuck_after;
90        let _ = self.durable.put(
91            Kind::Memory,
92            GOAL_STATE,
93            json!({"no_progress": if stuck_det { 0 } else { no_progress }, "last_progress": progress}),
94            None,
95        );
96
97        // 3. The LLM judge (check.via = agent|both). Runs async and refines the
98        //    disposition when its verdict arrives (on_goal_judge). Skipped if the
99        //    CEL already achieved, or a judge is still in flight.
100        let want_judge =
101            matches!(g.check.via.as_deref(), Some("agent") | Some("both") | None) && !achieved;
102        let judge_pending = self
103            .goal_judge_at
104            .is_some_and(|t| now_ms().saturating_sub(t) < JUDGE_STALE_MS);
105        if want_judge && !judge_pending {
106            self.spawn_goal_judge(&g, &state, no_progress, stuck_after);
107        }
108
109        // 4. Dispatch the DETERMINISTIC verdict now. When an LLM judge is running,
110        //    defer the not-yet-achieved decision to it (it may find achieved/stuck);
111        //    otherwise act on the CEL/counter verdict immediately.
112        let mut rearm = true;
113        if achieved {
114            self.log.info(
115                "goal.achieved",
116                json!({"statement": g.statement, "via": "condition"}),
117            );
118            rearm = self.dispatch_goal(
119                g.on_achieved.clone().unwrap_or(GoalAction::Finish),
120                "achieved",
121            );
122        } else if stuck_det && !(want_judge && !judge_pending) {
123            self.log.warn(
124                "goal.stuck",
125                json!({"via": "counter", "no_progress": no_progress, "stuck_after": stuck_after, "statement": g.statement}),
126            );
127            rearm = self.dispatch_goal(g.on_stuck.clone().unwrap_or(GoalAction::Replan), "stuck");
128        } else {
129            self.log.info(
130                "goal.check",
131                json!({"achieved": false, "no_progress": no_progress, "judge": want_judge && !judge_pending}),
132            );
133        }
134
135        if rearm && !self.draining {
136            let _ = self.timers.arm(
137                &self.durable,
138                now_ms() + goal_every_ms(&g),
139                json!({"kind": "goal"}),
140                json!({}),
141            );
142        }
143    }
144
145    /// An async goal LLM judge finished ([`Event::Background`] id `goal.judge`):
146    /// fold its verdict (`achieved` / `stuck`, combined with the counter) into a
147    /// disposition. Does not re-arm — `on_goal_check` already scheduled the cadence.
148    pub(crate) fn on_goal_judge(&mut self, result: &Value) {
149        self.goal_judge_at = None;
150        let Some(g) = self.settings.goal.clone() else {
151            return;
152        };
153        if let Some(err) = result.get("error").and_then(Value::as_str) {
154            self.log.warn("goal.judge.error", json!({"error": err}));
155            return;
156        }
157        let achieved = result["achieved"].as_bool().unwrap_or(false);
158        let stuck = result["stuck"].as_bool().unwrap_or(false)
159            || result["stuck_det"].as_bool().unwrap_or(false);
160        let reason = result["reason"].as_str().unwrap_or("");
161        if achieved {
162            self.log.info(
163                "goal.achieved",
164                json!({"statement": g.statement, "via": "judge", "reason": reason}),
165            );
166            let _ = self.dispatch_goal(
167                g.on_achieved.clone().unwrap_or(GoalAction::Finish),
168                "achieved",
169            );
170        } else if stuck {
171            self.log.warn(
172                "goal.stuck",
173                json!({"via": "judge", "statement": g.statement, "reason": reason}),
174            );
175            // Reset the counter so a corrected agent gets a fresh window.
176            let progress = self.status_value()["counters"]["runs_finished"]
177                .as_u64()
178                .unwrap_or(0);
179            let _ = self.durable.put(
180                Kind::Memory,
181                GOAL_STATE,
182                json!({"no_progress": 0, "last_progress": progress}),
183                None,
184            );
185            let _ = self.dispatch_goal(g.on_stuck.clone().unwrap_or(GoalAction::Replan), "stuck");
186        } else {
187            self.log
188                .info("goal.judge", json!({"achieved": false, "reason": reason}));
189        }
190    }
191
192    /// Spawn the LLM judge on an executor thread. It posts an [`Event::Background`]
193    /// (`goal.judge`) with `{achieved, stuck, reason}` (+ the counter's `stuck_det`).
194    fn spawn_goal_judge(&mut self, g: &Goal, state: &Value, no_progress: u64, stuck_after: u64) {
195        self.goal_judge_at = Some(now_ms());
196        let uri = self.intel_uri.clone();
197        let token = self.current_intel_bearer();
198        let headers = self.intel_headers.clone();
199        let aws_auth = self.intel_aws_auth();
200        let dialect = self.intel_dialect();
201        let model = self.model.clone();
202        let tx = self.events_tx.clone();
203        let statement = g.statement.clone().unwrap_or_default();
204        let stuck_det = no_progress >= stuck_after;
205        // A compact snapshot — enough for the judge, not the whole status dump.
206        let summary = json!({
207            "inbox_pending": state["inbox_pending"],
208            "runs": state["runs"],
209            "counters": state["counters"],
210            "conversations": state["conversations"],
211            "uptime_ms": state["uptime_ms"],
212            "no_progress_checks": no_progress,
213        });
214        self.log.info(
215            "goal.judge.start",
216            json!({"statement": statement, "no_progress": no_progress}),
217        );
218        std::thread::Builder::new()
219            .name("goal-judge".into())
220            .spawn(move || {
221                let mut result = goal_judge_call(
222                    &uri, token, &headers, aws_auth, dialect, &model, &statement, &summary,
223                );
224                if let Value::Object(m) = &mut result {
225                    m.insert("stuck_det".into(), json!(stuck_det));
226                }
227                let _ = tx.send(Event::Background {
228                    id: "goal.judge".into(),
229                    result,
230                });
231            })
232            .ok();
233    }
234
235    /// Apply a goal disposition. Returns whether the watchdog should re-arm
236    /// (false = it stops: `finish` drains, `idle` parks).
237    fn dispatch_goal(&mut self, action: GoalAction, why: &str) -> bool {
238        match action {
239            GoalAction::Finish => {
240                self.begin_drain(&format!("goal watchdog: {why} → finish"));
241                false
242            }
243            GoalAction::Idle => {
244                self.log.info(
245                    "goal.idle",
246                    json!({"reason": why, "note": "watchdog parked"}),
247                );
248                false
249            }
250            GoalAction::Workflow(name) => {
251                self.fire_goal_workflow(&name, why);
252                true
253            }
254            GoalAction::Replan => {
255                self.log.warn(
256                    "goal.replan",
257                    json!({"reason": why, "statement": self.goal_statement(), "note": "no progress; reconsider the approach"}),
258                );
259                true
260            }
261            GoalAction::Escalate => {
262                self.log.warn(
263                    "goal.escalate",
264                    json!({"reason": why, "statement": self.goal_statement()}),
265                );
266                true
267            }
268        }
269    }
270
271    /// Fire a named workflow's start node (manual/once) as a goal disposition.
272    fn fire_goal_workflow(&mut self, name: &str, why: &str) {
273        let start = self.workflows.get(name).and_then(|w| {
274            w.start_steps()
275                .into_iter()
276                .find(|s| s.kind == "manual" || s.kind == "once")
277                .map(|s| (s.id.clone(), s.spec.clone()))
278        });
279        match start {
280            Some((node, spec)) => {
281                self.log
282                    .info("goal.workflow", json!({"workflow": name, "reason": why}));
283                let payload = json!({"goal_reason": why, "statement": self.goal_statement()});
284                self.fire_start(name, &node, &spec, payload, "goal");
285            }
286            None => self.log.warn(
287                "goal.workflow.missing",
288                json!({"workflow": name, "note": "no manual/once start node to fire"}),
289            ),
290        }
291    }
292
293    fn goal_statement(&self) -> Option<String> {
294        self.settings
295            .goal
296            .as_ref()
297            .and_then(|g| g.statement.clone())
298    }
299}
300
301fn goal_every_ms(g: &Goal) -> u64 {
302    g.check
303        .every
304        .as_ref()
305        .map(|d| d.0.as_millis() as u64)
306        .filter(|&ms| ms > 0)
307        .unwrap_or(DEFAULT_EVERY_MS)
308}
309
310/// The blocking LLM call (on an executor thread): ask the model to judge the goal.
311#[allow(clippy::too_many_arguments)]
312fn goal_judge_call(
313    uri: &str,
314    token: Option<String>,
315    headers: &[(String, String)],
316    aws_auth: Option<crate::config::AuthSpec>,
317    dialect: Option<String>,
318    model: &str,
319    statement: &str,
320    summary: &Value,
321) -> Value {
322    let client = match IntelClient::from_parts(uri, token) {
323        Ok(c) => {
324            #[allow(unused_mut)]
325            let mut c = c
326                .with_headers(headers.to_vec())
327                // The judge dials the same endpoint a turn does, so it must
328                // speak the configured wire dialect and carry the configured
329                // headers — otherwise a working agent has a broken watchdog.
330                .with_dialect(dialect.as_deref());
331            // Sign the judge's dial when the endpoint uses AWS auth, for the
332            // same reason: the credential path cannot differ from a turn's.
333            #[cfg(feature = "oauth")]
334            if let Some(aws) = &aws_auth
335                && let Ok(s) = crate::auth::aws::SigV4Signer::from_spec(aws, "intelligence")
336            {
337                c = c.with_signer(Some(s as std::sync::Arc<dyn ::mcp::http::RequestSigner>));
338            }
339            #[cfg(not(feature = "oauth"))]
340            let _ = &aws_auth;
341            c
342        }
343        Err(e) => return json!({"achieved": false, "stuck": false, "error": format!("intel: {e}")}),
344    };
345    let system = "You are the goal supervisor for an autonomous agent. Given the GOAL and the agent's current STATE, judge whether the goal is achieved and whether the agent is stuck (making no meaningful progress toward it). Reply with ONLY compact JSON: {\"achieved\": <bool>, \"stuck\": <bool>, \"reason\": \"<short>\"}.";
346    let user = format!(
347        "GOAL: {statement}\n\nSTATE:\n{}\n\nJudge now.",
348        serde_json::to_string(summary).unwrap_or_default()
349    );
350    let req = Request {
351        model: model.to_string(),
352        messages: vec![Message::System(system.to_string()), Message::User(user)],
353        tools: vec![],
354        max_tokens: 300,
355        temperature: Some(0.0),
356    };
357    match client.complete(&req) {
358        Ok(resp) => parse_verdict(&resp.text.unwrap_or_default()),
359        Err(e) => json!({"achieved": false, "stuck": false, "error": format!("intel: {e}")}),
360    }
361}
362
363/// Extract `{achieved, stuck, reason}` from the model's reply (tolerant of prose
364/// around the JSON object).
365fn parse_verdict(text: &str) -> Value {
366    let obj = extract_json_object(text).unwrap_or_else(|| json!({}));
367    json!({
368        "achieved": obj["achieved"].as_bool().unwrap_or(false),
369        "stuck": obj["stuck"].as_bool().unwrap_or(false),
370        "reason": obj["reason"].as_str().unwrap_or(""),
371    })
372}
373
374/// The first balanced `{…}` JSON object in `text`, parsed (or `None`).
375fn extract_json_object(text: &str) -> Option<Value> {
376    let start = text.find('{')?;
377    let end = text.rfind('}')?;
378    if end <= start {
379        return None;
380    }
381    serde_json::from_str(&text[start..=end]).ok()
382}