1use 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
28const DEFAULT_EVERY_MS: u64 = 300_000; const GOAL_STATE: &str = "_goal/state";
32const JUDGE_STALE_MS: u64 = 120_000;
34
35impl crate::runtime::reactor::Runtime {
36 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 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 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 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 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 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 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 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 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 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 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 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#[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 .with_dialect(dialect.as_deref());
331 #[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
363fn 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
374fn 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}