#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct StateDef {
pub description: Option<String>,
#[serde(default)]
pub prompt_template: Option<StatePromptTemplateRef>,
#[serde(default)]
pub instructions: Option<String>,
#[serde(default)]
pub personality: Option<String>,
#[serde(default)]
pub initial: bool,
#[serde(default, rename = "final")]
pub terminal: bool,
#[serde(default)]
pub gating: bool,
#[serde(default)]
pub concurrent: bool,
#[serde(default)]
pub poll: Option<PollConfig>,
pub visits: Option<u32>,
#[serde(default)]
pub execute_on: Option<String>,
#[serde(default)]
pub snapshot: Option<StateSnapshotConfig>,
#[serde(default)]
pub handoff: Option<StateHandoffConfig>,
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub all_targets: Vec<String>,
#[serde(default)]
pub target_locked: bool,
#[serde(default)]
pub all_models: Vec<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub agent: Option<AgentConfig>,
#[serde(default)]
pub agent_mode: Option<String>,
#[serde(default)]
pub agent_timeout: Option<String>,
#[serde(default)]
pub program: Option<serde_yaml::Value>,
#[serde(default)]
pub program_timeout: Option<String>,
#[serde(default)]
pub inputs: Vec<StateArtifactDef>,
#[serde(default)]
pub outputs: Vec<StateArtifactDef>,
#[serde(default)]
pub mcp_servers: Option<Vec<StateMcpEntry>>,
#[serde(default)]
pub skills: Option<Vec<StateSkillEntry>>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PromptTemplateDef {
pub instructions: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum StatePromptTemplateRef {
Name(String),
WithValues {
name: String,
#[serde(default)]
values: IndexMap<String, serde_yaml::Value>,
},
}
impl StatePromptTemplateRef {
pub fn name(&self) -> &str {
match self {
StatePromptTemplateRef::Name(name) => name,
StatePromptTemplateRef::WithValues { name, .. } => name,
}
}
pub fn values(&self) -> Option<&IndexMap<String, serde_yaml::Value>> {
match self {
StatePromptTemplateRef::Name(_) => None,
StatePromptTemplateRef::WithValues { values, .. } => Some(values),
}
}
pub fn scalar_value(&self, key: &str) -> Option<String> {
let value = self.values()?.get(key)?;
match value {
serde_yaml::Value::Null => Some(String::new()),
serde_yaml::Value::Bool(value) => Some(value.to_string()),
serde_yaml::Value::Number(value) => Some(value.to_string()),
serde_yaml::Value::String(value) => Some(value.clone()),
_ => None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PollConfig {
pub interval: String,
pub max_attempts: u32,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct StateSnapshotConfig {
#[serde(default)]
pub emit: Option<SnapshotEmitConfig>,
#[serde(default)]
pub inherit: Option<SnapshotInheritConfig>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SnapshotEmitConfig {
pub name: String,
#[serde(default)]
pub on: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SnapshotInheritConfig {
pub name: String,
#[serde(default, rename = "from")]
pub from_axis: Option<String>,
#[serde(default)]
pub compat: Option<String>,
#[serde(default)]
pub required: Option<bool>,
#[serde(default)]
pub select: Option<SnapshotInheritSelectConfig>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SnapshotInheritSelectConfig {
#[serde(default)]
pub state: Option<String>,
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub visit: Option<serde_yaml::Value>,
#[serde(default)]
pub generation: Option<serde_yaml::Value>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct StateHandoffConfig {
#[serde(default)]
pub inherit: Vec<HandoffInheritConfig>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct HandoffInheritConfig {
#[serde(rename = "from")]
pub from_axis: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub merge: Option<String>,
}
fn validate_snapshot_name(
state_name: &str,
field: &str,
value: &str,
) -> Result<(), StateMachineLoadError> {
let valid = value.len() <= 64
&& value.bytes().next().is_some_and(|first| first.is_ascii_lowercase())
&& value
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-');
if valid {
Ok(())
} else {
Err(StateMachineLoadError::Invalid(format!(
"state '{state_name}' has invalid {field} '{value}' (expected ^[a-z][a-z0-9-]*$, max 64 characters)"
)))
}
}
fn validate_snapshot_selector_value(
state_name: &str,
field: &str,
value: &serde_yaml::Value,
allowed_strings: &[&str],
) -> Result<(), StateMachineLoadError> {
let valid = match value {
serde_yaml::Value::String(value) => {
allowed_strings.contains(&value.as_str())
|| value.parse::<u64>().is_ok_and(|number| number >= 1)
}
serde_yaml::Value::Number(number) => number.as_u64().is_some_and(|number| number >= 1),
_ => false,
};
if valid {
Ok(())
} else {
Err(StateMachineLoadError::Invalid(format!(
"state '{state_name}' has invalid {field} value '{value:?}'"
)))
}
}
fn state_declares_snapshot_target_shape(state: &StateDef) -> bool {
state.target.is_some()
|| !state.all_targets.is_empty()
|| !state.all_models.is_empty()
|| state.model.is_some()
|| state.agent.is_some()
}
fn state_declares_snapshot_fanout_source(state: &StateDef) -> bool {
!state.all_targets.is_empty() || !state.all_models.is_empty()
}
fn statically_resolved_snapshot_agent(state: &StateDef) -> Option<String> {
if let Some(selector) = state.target.as_deref() {
return parse_execution_target(selector).ok().map(|target| target.agent);
}
if !state.all_targets.is_empty() {
let mut agents = state.all_targets.iter().filter_map(|selector| {
parse_execution_target(selector).ok().map(|target| target.agent)
});
let first = agents.next()?;
if agents.all(|agent| agent == first) {
return Some(first);
}
return None;
}
state.agent.as_ref().map(|agent| agent.id().to_string())
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Profile {
pub initial: String,
pub allowed: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct NodePolicy {
pub root: String,
pub default: String,
#[serde(default)]
pub by_type: IndexMap<String, String>,
#[serde(default)]
pub overrides: Vec<NodePolicyOverride>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct NodePolicyOverride {
#[serde(rename = "match")]
pub match_: NodePolicyMatch,
pub profile: String,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct NodePolicyMatch {
#[serde(default, rename = "type")]
pub node_type: Option<String>,
#[serde(default)]
pub level: Option<u8>,
}
impl NodePolicyMatch {
fn matches(&self, kind: &str, level: u8) -> bool {
self.node_type.as_deref().is_none_or(|want| want.eq_ignore_ascii_case(kind))
&& self.level.is_none_or(|want| want == level)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct StateMachine {
pub name: String,
#[serde(default)]
pub models: Vec<String>,
#[serde(default)]
pub prompt_templates: IndexMap<String, PromptTemplateDef>,
pub version: serde_yaml::Value,
pub states: IndexMap<String, StateDef>,
#[serde(default)]
pub transitions: Vec<TransitionRule>,
#[serde(default)]
pub profiles: Option<IndexMap<String, Profile>>,
#[serde(default)]
pub node_policy: Option<NodePolicy>,
}
const DEFAULT_STATES_YAML: &str = include_str!("../default-states.yaml");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SupervisionScope {
Child,
Descendant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SupervisionEvent {
Terminal,
Transition,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExecuteOn {
ChildTerminal,
ChildTransition,
DescendantTerminal,
DescendantTransition,
}
impl ExecuteOn {
pub const VALUES: [&'static str; 4] = [
"child-terminal",
"child-transition",
"descendant-terminal",
"descendant-transition",
];
pub fn parse(value: &str) -> Option<Self> {
match value.trim() {
"child-terminal" => Some(Self::ChildTerminal),
"child-transition" => Some(Self::ChildTransition),
"descendant-terminal" => Some(Self::DescendantTerminal),
"descendant-transition" => Some(Self::DescendantTransition),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::ChildTerminal => "child-terminal",
Self::ChildTransition => "child-transition",
Self::DescendantTerminal => "descendant-terminal",
Self::DescendantTransition => "descendant-transition",
}
}
pub fn scope(self) -> SupervisionScope {
match self {
Self::ChildTerminal | Self::ChildTransition => SupervisionScope::Child,
Self::DescendantTerminal | Self::DescendantTransition => SupervisionScope::Descendant,
}
}
pub fn event(self) -> SupervisionEvent {
match self {
Self::ChildTerminal | Self::DescendantTerminal => SupervisionEvent::Terminal,
Self::ChildTransition | Self::DescendantTransition => SupervisionEvent::Transition,
}
}
}
impl StateDef {
pub fn execute_on(&self) -> Option<ExecuteOn> {
self.execute_on.as_deref().and_then(ExecuteOn::parse)
}
pub fn is_agent_bearing(&self) -> bool {
self.agent.is_some()
|| self.target.is_some()
|| self.model.is_some()
|| !self.all_targets.is_empty()
|| !self.all_models.is_empty()
}
}