Skip to main content

starweaver_runtime/
graph.rs

1//! Deterministic agent-loop graph transitions.
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6use crate::run::{AgentRunState, RunStatus};
7
8/// Agent runtime graph node.
9#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
10#[serde(rename_all = "snake_case")]
11pub enum AgentNode {
12    /// Enter run and initialize state.
13    StartRun,
14    /// Build the next canonical request.
15    PrepareRequest,
16    /// Deliver queued steering messages.
17    DrainMessages,
18    /// Call model adapter.
19    ModelRequest,
20    /// Classify response as final output, tool calls, or retry.
21    HandleResponse,
22    /// Execute a batch of tool calls.
23    ExecuteTools,
24    /// Finalize output and export state.
25    FinalizeRun,
26    /// Drain idle messages after final output appears.
27    DrainIdleMessages,
28    /// Terminal node.
29    Complete,
30}
31
32/// Pure graph transition decision.
33#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
34pub struct GraphDecision {
35    /// Next node to execute.
36    pub next: AgentNode,
37    /// Whether a checkpoint should be written at this boundary.
38    pub checkpoint: bool,
39}
40
41/// One inspected graph transition.
42#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
43pub struct AgentGraphStep {
44    /// Step index within the inspected graph walk.
45    pub index: usize,
46    /// Node inspected for this transition.
47    pub current: AgentNode,
48    /// Transition decision returned by the graph.
49    pub decision: GraphDecision,
50    /// Completed runtime step from the inspected state.
51    pub run_step: usize,
52    /// Runtime status from the inspected state.
53    pub status: RunStatus,
54}
55
56/// A compact graph inspection report.
57#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
58pub struct AgentGraphTrace {
59    /// Transition steps in walk order.
60    pub steps: Vec<AgentGraphStep>,
61    /// Last node reached by the inspection.
62    pub terminal: AgentNode,
63}
64
65impl AgentGraphTrace {
66    /// Return all inspected steps.
67    #[must_use]
68    pub fn steps(&self) -> &[AgentGraphStep] {
69        &self.steps
70    }
71
72    /// Return whether the trace reached the complete node.
73    #[must_use]
74    pub const fn is_complete(&self) -> bool {
75        matches!(self.terminal, AgentNode::Complete)
76    }
77}
78
79impl GraphDecision {
80    const fn checkpoint(next: AgentNode) -> Self {
81        Self {
82            next,
83            checkpoint: true,
84        }
85    }
86
87    const fn step(next: AgentNode) -> Self {
88        Self {
89            next,
90            checkpoint: false,
91        }
92    }
93}
94
95/// Graph transition error.
96#[derive(Debug, Error)]
97pub enum GraphError {
98    /// The current node cannot transition with the provided state.
99    #[error("invalid graph state at {node:?}: {reason}")]
100    InvalidState {
101        /// Current node.
102        node: AgentNode,
103        /// Human-readable reason.
104        reason: String,
105    },
106}
107
108/// Determine the next graph node from current node and state.
109///
110/// # Errors
111///
112/// Returns an error when the current node requires state produced by an earlier handler.
113pub fn next_node(
114    current: AgentNode,
115    state: &AgentRunState,
116    max_steps: usize,
117) -> Result<GraphDecision, GraphError> {
118    if state.run_step >= max_steps
119        && !matches!(
120            current,
121            AgentNode::FinalizeRun | AgentNode::DrainIdleMessages | AgentNode::Complete
122        )
123    {
124        return Ok(GraphDecision::checkpoint(AgentNode::FinalizeRun));
125    }
126
127    match current {
128        AgentNode::StartRun => Ok(GraphDecision::checkpoint(AgentNode::PrepareRequest)),
129        AgentNode::PrepareRequest => Ok(GraphDecision::checkpoint(AgentNode::DrainMessages)),
130        AgentNode::DrainMessages => Ok(GraphDecision::step(AgentNode::ModelRequest)),
131        AgentNode::ModelRequest => {
132            if state.latest_response.is_some() {
133                Ok(GraphDecision::checkpoint(AgentNode::HandleResponse))
134            } else {
135                Err(GraphError::InvalidState {
136                    node: current,
137                    reason: "model response is required".to_string(),
138                })
139            }
140        }
141        AgentNode::HandleResponse => {
142            if !state.pending_tool_calls.is_empty() {
143                Ok(GraphDecision::checkpoint(AgentNode::ExecuteTools))
144            } else if state.output.is_some() {
145                Ok(GraphDecision::checkpoint(AgentNode::FinalizeRun))
146            } else if !state.pending_tool_returns.is_empty() {
147                Ok(GraphDecision::checkpoint(AgentNode::PrepareRequest))
148            } else {
149                Err(GraphError::InvalidState {
150                    node: current,
151                    reason: "tool calls, output, or retry signal is required".to_string(),
152                })
153            }
154        }
155        AgentNode::ExecuteTools => {
156            if state.pending_tool_returns.is_empty() {
157                Err(GraphError::InvalidState {
158                    node: current,
159                    reason: "tool returns are required after tool execution".to_string(),
160                })
161            } else {
162                Ok(GraphDecision::checkpoint(AgentNode::PrepareRequest))
163            }
164        }
165        AgentNode::FinalizeRun => {
166            if matches!(
167                state.status,
168                RunStatus::Completed | RunStatus::Failed | RunStatus::Cancelled
169            ) || state.output.is_some()
170            {
171                Ok(GraphDecision::checkpoint(AgentNode::DrainIdleMessages))
172            } else {
173                Err(GraphError::InvalidState {
174                    node: current,
175                    reason: "terminal output or terminal status is required".to_string(),
176                })
177            }
178        }
179        AgentNode::DrainIdleMessages => {
180            if state.idle_messages.is_empty() {
181                Ok(GraphDecision::checkpoint(AgentNode::Complete))
182            } else {
183                Ok(GraphDecision::checkpoint(AgentNode::PrepareRequest))
184            }
185        }
186        AgentNode::Complete => Ok(GraphDecision::step(AgentNode::Complete)),
187    }
188}
189
190/// Inspect the next graph transition from a node and state.
191///
192/// # Errors
193///
194/// Returns an error when the current node requires state produced by an earlier handler.
195pub fn inspect_next_node(
196    current: AgentNode,
197    state: &AgentRunState,
198    max_steps: usize,
199) -> Result<AgentGraphStep, GraphError> {
200    let decision = next_node(current, state, max_steps)?;
201    Ok(AgentGraphStep {
202        index: 0,
203        current,
204        decision,
205        run_step: state.run_step,
206        status: state.status,
207    })
208}
209
210/// Walk graph transitions for inspection using a static state snapshot.
211///
212/// This is intended for application debuggers and durable runtime diagnostics. Runtime handlers mutate
213/// state between nodes during a real run, so this helper stops at the first transition that needs state
214/// produced by an earlier handler.
215///
216/// # Errors
217///
218/// Returns an error when the inspected transition is invalid for the provided state snapshot.
219pub fn inspect_graph(
220    start: AgentNode,
221    state: &AgentRunState,
222    max_steps: usize,
223    max_transitions: usize,
224) -> Result<AgentGraphTrace, GraphError> {
225    let mut current = start;
226    let mut steps = Vec::new();
227    for index in 0..max_transitions {
228        let decision = next_node(current, state, max_steps)?;
229        let next = decision.next;
230        steps.push(AgentGraphStep {
231            index,
232            current,
233            decision,
234            run_step: state.run_step,
235            status: state.status,
236        });
237        current = next;
238        if matches!(current, AgentNode::Complete) {
239            break;
240        }
241    }
242    Ok(AgentGraphTrace {
243        steps,
244        terminal: current,
245    })
246}