use serde::{Deserialize, Serialize};
use crate::actions::PlannedAction;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum NextMove {
Batch {
purpose: String,
steps: Vec<Step>,
},
Done {
summary: String,
#[serde(default)]
extracted_data: serde_json::Value,
},
Fail { reason: String },
Clarify { question: String },
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RuntimeCaps {
pub cdp_bound: bool,
pub cdp_browser: Option<String>,
pub cdp_url: Option<String>,
pub native_input: bool,
pub steps_used: u32,
pub max_steps: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttemptRecord {
pub step_purpose: String,
pub action: PlannedAction,
pub succeeded: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub data: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_action_hint: Option<crate::NextActionHint>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Step {
pub purpose: String,
#[serde(default, deserialize_with = "deserialize_step_kind_lenient")]
pub kind: StepKind,
pub action: PlannedAction,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StepKind {
#[default]
Deterministic,
LlmAssisted,
}
fn deserialize_step_kind_lenient<'de, D>(deserializer: D) -> Result<StepKind, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw: Option<String> = Option::deserialize(deserializer)?;
Ok(match raw.as_deref() {
Some("llm_assisted") | Some("LlmAssisted") => StepKind::LlmAssisted,
_ => StepKind::Deterministic,
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum StepResult {
Ok {
#[serde(default)]
data: serde_json::Value,
#[serde(default)]
discovered_sub_goal: Option<serde_json::Value>,
},
Err {
message: String,
#[serde(default = "default_recoverable")]
recoverable: bool,
},
}
fn default_recoverable() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum GoalOutcome {
Succeeded {
summary: String,
#[serde(default)]
extracted_data: serde_json::Value,
},
Failed(FailureReport),
Refused {
summary: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FailureReport {
pub failing_sub_goal: String,
pub failing_step: String,
pub attempts: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunLimits {
pub max_steps: u32,
pub timeout_ms: u64,
pub max_step_retries: u32,
#[serde(default)]
pub terminal_app: Option<String>,
#[serde(default)]
pub workflow_id_for_memory: Option<String>,
#[serde(default)]
pub memory_db_path: Option<String>,
}
impl Default for RunLimits {
fn default() -> Self {
Self {
max_steps: 80,
timeout_ms: 900_000,
max_step_retries: 3,
terminal_app: None,
workflow_id_for_memory: None,
memory_db_path: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_limits_default_is_the_documented_default() {
let l = RunLimits::default();
assert_eq!(l.max_step_retries, 3);
assert_eq!(l.max_steps, 80);
assert_eq!(l.timeout_ms, 900_000);
}
#[test]
fn step_result_tags_are_snake_case() {
let ok = StepResult::Ok {
data: serde_json::json!({"x": 1}),
discovered_sub_goal: None,
};
let err = StepResult::Err {
message: "selector returned null".into(),
recoverable: true,
};
assert!(serde_json::to_string(&ok)
.unwrap()
.contains("\"status\":\"ok\""));
assert!(serde_json::to_string(&err)
.unwrap()
.contains("\"status\":\"err\""));
}
#[test]
fn clarify_next_move_round_trips_through_json() {
let mv = NextMove::Clarify {
question: "What should I delete?".into(),
};
let json = serde_json::to_string(&mv).unwrap();
assert!(json.contains("\"kind\":\"clarify\""));
assert!(json.contains("\"question\":\"What should I delete?\""));
let back: NextMove = serde_json::from_str(&json).unwrap();
assert!(matches!(back, NextMove::Clarify { .. }));
}
#[test]
fn refused_outcome_serializes_with_summary() {
let outcome = GoalOutcome::Refused {
summary: "Which item should I delete? Please clarify.".into(),
};
let json = serde_json::to_string(&outcome).unwrap();
assert!(json.contains("\"status\":\"refused\""));
assert!(json.contains("clarify"));
let back: GoalOutcome = serde_json::from_str(&json).unwrap();
assert!(matches!(back, GoalOutcome::Refused { .. }));
}
#[test]
fn failure_report_surfaces_last_three_attempts() {
let report = FailureReport {
failing_sub_goal: "gather BTC price".into(),
failing_step: "extract price via innerText regex".into(),
attempts: vec![
"selector returned null".into(),
"consent wall blocked script".into(),
"network error".into(),
],
};
let outcome = GoalOutcome::Failed(report.clone());
let json = serde_json::to_string(&outcome).unwrap();
assert!(json.contains("\"status\":\"failed\""));
assert!(json.contains("gather BTC price"));
assert!(json.contains("network error"));
}
}