use serde_json::{Value, json};
use crate::config::v2::{Goal, GoalAction};
use crate::intel::client::IntelClient;
use crate::runtime::events::Event;
use crate::state::{Kind, now_ms};
use crate::wire::intel::{Message, Request};
const DEFAULT_EVERY_MS: u64 = 300_000; const GOAL_STATE: &str = "_goal/state";
const JUDGE_STALE_MS: u64 = 120_000;
impl crate::runtime::reactor::Runtime {
pub(crate) fn arm_goal(&mut self) {
let Some(g) = self.settings.goal.clone() else {
return;
};
let every = goal_every_ms(&g);
let _ = self.timers.arm(
&self.durable,
now_ms() + every,
json!({"kind": "goal"}),
json!({}),
);
self.log.info(
"goal.armed",
json!({"every_ms": every, "statement": g.statement, "via": g.check.via.as_deref().unwrap_or("both"), "stuck_after": g.stuck_after.unwrap_or(3)}),
);
}
pub(crate) fn on_goal_check(&mut self, _payload: &Value) {
let Some(g) = self.settings.goal.clone() else {
return;
};
let state = self.status_value();
let mut achieved = false;
if let Some(cond) = &g.check.condition {
let expr = cond.trim().trim_start_matches("CEL:").trim();
achieved = crate::cel::eval_bool(expr, &[("state", &state)]).unwrap_or(false);
}
let progress = state["counters"]["runs_finished"].as_u64().unwrap_or(0)
+ state["counters"]["turns"].as_u64().unwrap_or(0);
let prev = self
.durable
.get(Kind::Memory, GOAL_STATE)
.ok()
.flatten()
.map(|e| e.state)
.unwrap_or_else(|| json!({"no_progress": 0, "last_progress": 0}));
let last = prev["last_progress"].as_u64().unwrap_or(0);
let mut no_progress = prev["no_progress"].as_u64().unwrap_or(0);
if achieved || progress > last {
no_progress = 0;
} else {
no_progress += 1;
}
let stuck_after = g.stuck_after.unwrap_or(3) as u64;
let stuck_det = !achieved && no_progress >= stuck_after;
let _ = self.durable.put(
Kind::Memory,
GOAL_STATE,
json!({"no_progress": if stuck_det { 0 } else { no_progress }, "last_progress": progress}),
None,
);
let want_judge =
matches!(g.check.via.as_deref(), Some("agent") | Some("both") | None) && !achieved;
let judge_pending = self
.goal_judge_at
.is_some_and(|t| now_ms().saturating_sub(t) < JUDGE_STALE_MS);
if want_judge && !judge_pending {
self.spawn_goal_judge(&g, &state, no_progress, stuck_after);
}
let mut rearm = true;
if achieved {
self.log.info(
"goal.achieved",
json!({"statement": g.statement, "via": "condition"}),
);
rearm = self.dispatch_goal(
g.on_achieved.clone().unwrap_or(GoalAction::Finish),
"achieved",
);
} else if stuck_det && !(want_judge && !judge_pending) {
self.log.warn(
"goal.stuck",
json!({"via": "counter", "no_progress": no_progress, "stuck_after": stuck_after, "statement": g.statement}),
);
rearm = self.dispatch_goal(g.on_stuck.clone().unwrap_or(GoalAction::Replan), "stuck");
} else {
self.log.info(
"goal.check",
json!({"achieved": false, "no_progress": no_progress, "judge": want_judge && !judge_pending}),
);
}
if rearm && !self.draining {
let _ = self.timers.arm(
&self.durable,
now_ms() + goal_every_ms(&g),
json!({"kind": "goal"}),
json!({}),
);
}
}
pub(crate) fn on_goal_judge(&mut self, result: &Value) {
self.goal_judge_at = None;
let Some(g) = self.settings.goal.clone() else {
return;
};
if let Some(err) = result.get("error").and_then(Value::as_str) {
self.log.warn("goal.judge.error", json!({"error": err}));
return;
}
let achieved = result["achieved"].as_bool().unwrap_or(false);
let stuck = result["stuck"].as_bool().unwrap_or(false)
|| result["stuck_det"].as_bool().unwrap_or(false);
let reason = result["reason"].as_str().unwrap_or("");
if achieved {
self.log.info(
"goal.achieved",
json!({"statement": g.statement, "via": "judge", "reason": reason}),
);
let _ = self.dispatch_goal(
g.on_achieved.clone().unwrap_or(GoalAction::Finish),
"achieved",
);
} else if stuck {
self.log.warn(
"goal.stuck",
json!({"via": "judge", "statement": g.statement, "reason": reason}),
);
let progress = self.status_value()["counters"]["runs_finished"]
.as_u64()
.unwrap_or(0);
let _ = self.durable.put(
Kind::Memory,
GOAL_STATE,
json!({"no_progress": 0, "last_progress": progress}),
None,
);
let _ = self.dispatch_goal(g.on_stuck.clone().unwrap_or(GoalAction::Replan), "stuck");
} else {
self.log
.info("goal.judge", json!({"achieved": false, "reason": reason}));
}
}
fn spawn_goal_judge(&mut self, g: &Goal, state: &Value, no_progress: u64, stuck_after: u64) {
self.goal_judge_at = Some(now_ms());
let uri = self.intel_uri.clone();
let token = self.current_intel_bearer();
let headers = self.intel_headers.clone();
let aws_auth = self.intel_aws_auth();
let dialect = self.intel_dialect();
let model = self.model.clone();
let tx = self.events_tx.clone();
let statement = g.statement.clone().unwrap_or_default();
let stuck_det = no_progress >= stuck_after;
let summary = json!({
"inbox_pending": state["inbox_pending"],
"runs": state["runs"],
"counters": state["counters"],
"conversations": state["conversations"],
"uptime_ms": state["uptime_ms"],
"no_progress_checks": no_progress,
});
self.log.info(
"goal.judge.start",
json!({"statement": statement, "no_progress": no_progress}),
);
std::thread::Builder::new()
.name("goal-judge".into())
.spawn(move || {
let mut result = goal_judge_call(
&uri, token, &headers, aws_auth, dialect, &model, &statement, &summary,
);
if let Value::Object(m) = &mut result {
m.insert("stuck_det".into(), json!(stuck_det));
}
let _ = tx.send(Event::Background {
id: "goal.judge".into(),
result,
});
})
.ok();
}
fn dispatch_goal(&mut self, action: GoalAction, why: &str) -> bool {
match action {
GoalAction::Finish => {
self.begin_drain(&format!("goal watchdog: {why} → finish"));
false
}
GoalAction::Idle => {
self.log.info(
"goal.idle",
json!({"reason": why, "note": "watchdog parked"}),
);
false
}
GoalAction::Workflow(name) => {
self.fire_goal_workflow(&name, why);
true
}
GoalAction::Replan => {
self.log.warn(
"goal.replan",
json!({"reason": why, "statement": self.goal_statement(), "note": "no progress; reconsider the approach"}),
);
true
}
GoalAction::Escalate => {
self.log.warn(
"goal.escalate",
json!({"reason": why, "statement": self.goal_statement()}),
);
true
}
}
}
fn fire_goal_workflow(&mut self, name: &str, why: &str) {
let start = self.workflows.get(name).and_then(|w| {
w.start_steps()
.into_iter()
.find(|s| s.kind == "manual" || s.kind == "once")
.map(|s| (s.id.clone(), s.spec.clone()))
});
match start {
Some((node, spec)) => {
self.log
.info("goal.workflow", json!({"workflow": name, "reason": why}));
let payload = json!({"goal_reason": why, "statement": self.goal_statement()});
self.fire_start(name, &node, &spec, payload, "goal");
}
None => self.log.warn(
"goal.workflow.missing",
json!({"workflow": name, "note": "no manual/once start node to fire"}),
),
}
}
fn goal_statement(&self) -> Option<String> {
self.settings
.goal
.as_ref()
.and_then(|g| g.statement.clone())
}
}
fn goal_every_ms(g: &Goal) -> u64 {
g.check
.every
.as_ref()
.map(|d| d.0.as_millis() as u64)
.filter(|&ms| ms > 0)
.unwrap_or(DEFAULT_EVERY_MS)
}
#[allow(clippy::too_many_arguments)]
fn goal_judge_call(
uri: &str,
token: Option<String>,
headers: &[(String, String)],
aws_auth: Option<crate::config::AuthSpec>,
dialect: Option<String>,
model: &str,
statement: &str,
summary: &Value,
) -> Value {
let client = match IntelClient::from_parts(uri, token) {
Ok(c) => {
#[allow(unused_mut)]
let mut c = c
.with_headers(headers.to_vec())
.with_dialect(dialect.as_deref());
#[cfg(feature = "oauth")]
if let Some(aws) = &aws_auth
&& let Ok(s) = crate::auth::aws::SigV4Signer::from_spec(aws, "intelligence")
{
c = c.with_signer(Some(s as std::sync::Arc<dyn ::mcp::http::RequestSigner>));
}
#[cfg(not(feature = "oauth"))]
let _ = &aws_auth;
c
}
Err(e) => return json!({"achieved": false, "stuck": false, "error": format!("intel: {e}")}),
};
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>\"}.";
let user = format!(
"GOAL: {statement}\n\nSTATE:\n{}\n\nJudge now.",
serde_json::to_string(summary).unwrap_or_default()
);
let req = Request {
model: model.to_string(),
messages: vec![Message::System(system.to_string()), Message::User(user)],
tools: vec![],
max_tokens: 300,
temperature: Some(0.0),
};
match client.complete(&req) {
Ok(resp) => parse_verdict(&resp.text.unwrap_or_default()),
Err(e) => json!({"achieved": false, "stuck": false, "error": format!("intel: {e}")}),
}
}
fn parse_verdict(text: &str) -> Value {
let obj = extract_json_object(text).unwrap_or_else(|| json!({}));
json!({
"achieved": obj["achieved"].as_bool().unwrap_or(false),
"stuck": obj["stuck"].as_bool().unwrap_or(false),
"reason": obj["reason"].as_str().unwrap_or(""),
})
}
fn extract_json_object(text: &str) -> Option<Value> {
let start = text.find('{')?;
let end = text.rfind('}')?;
if end <= start {
return None;
}
serde_json::from_str(&text[start..=end]).ok()
}