Skip to main content

greentic_aw_runtime/graph/
state.rs

1//! Run-scoped state threaded through the agent-graph executor.
2//!
3//! Ported from the greentic-designer spike
4//! (`src/orchestrate/agent_graph/state.rs`), with `TriageState` renamed to
5//! `GraphRunState` and `Role`/`Message` renamed to `GraphRole`/`GraphMessage`
6//! for namespace clarity at the runner level.
7//!
8//! Designer-only helpers intentionally not ported:
9//! - `TriageState::new(user_input)` — the runner initialises state via
10//!   `Default` and the executor pushes the first user message explicitly;
11//!   there is no single-argument constructor needed in this crate yet.
12
13use serde::{Deserialize, Serialize};
14
15// ---------------------------------------------------------------------------
16// GraphRole
17// ---------------------------------------------------------------------------
18
19/// Speaker role for a message in the agent conversation.
20///
21/// Serialised as lowercase strings (`"user"`, `"assistant"`, `"tool"`) to
22/// match the wire format used by the designer spike and OpenAI-compatible
23/// providers.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum GraphRole {
27    User,
28    Assistant,
29    Tool,
30}
31
32// ---------------------------------------------------------------------------
33// GraphMessage
34// ---------------------------------------------------------------------------
35
36/// A single turn in the conversation log.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct GraphMessage {
39    pub role: GraphRole,
40    pub content: String,
41}
42
43// ---------------------------------------------------------------------------
44// GraphRunState
45// ---------------------------------------------------------------------------
46
47/// Run-scoped state threaded through the executor. `messages` is append-only
48/// (never replaced) so a resumed run can deterministically reconstruct the
49/// conversation. `resolved` is set by the agent turn; `iterations` counts
50/// how many times the router has looped back to the agent.
51#[derive(Debug, Clone, Default, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase")]
53pub struct GraphRunState {
54    /// Append-only conversation log.
55    pub messages: Vec<GraphMessage>,
56    /// Set `true` by the agent when the issue is considered resolved.
57    pub resolved: bool,
58    /// Number of completed router→agent loop iterations.
59    pub iterations: u32,
60    /// Free-form agent working memory; opaque to the executor.
61    #[serde(default)]
62    pub scratchpad: serde_json::Value,
63}
64
65impl GraphRunState {
66    /// Append a message. Messages are never replaced — this is the reducer.
67    pub fn push_message(&mut self, role: GraphRole, content: impl Into<String>) {
68        self.messages.push(GraphMessage {
69            role,
70            content: content.into(),
71        });
72    }
73}
74
75// ---------------------------------------------------------------------------
76// Tests
77// ---------------------------------------------------------------------------
78
79#[cfg(test)]
80#[allow(clippy::unwrap_used, clippy::expect_used)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn push_message_appends_never_replaces() {
86        let mut s = GraphRunState::default();
87        s.push_message(GraphRole::User, "hi");
88        assert_eq!(s.messages.len(), 1);
89        s.push_message(GraphRole::Assistant, "hello");
90        s.push_message(GraphRole::Tool, "{\"found\":true}");
91        assert_eq!(s.messages.len(), 3);
92        assert_eq!(s.messages[0].role, GraphRole::User);
93        assert_eq!(s.messages[2].content, "{\"found\":true}");
94    }
95
96    #[test]
97    fn json_round_trips_camel_case() {
98        let mut s = GraphRunState::default();
99        s.push_message(GraphRole::User, "hi");
100        s.resolved = true;
101        s.iterations = 2;
102        let json = serde_json::to_string(&s).unwrap();
103        assert!(json.contains("\"iterations\":2"), "json: {json}");
104        let back: GraphRunState = serde_json::from_str(&json).unwrap();
105        assert_eq!(back.resolved, s.resolved);
106        assert_eq!(back.iterations, s.iterations);
107        assert_eq!(back.messages.len(), s.messages.len());
108    }
109
110    #[test]
111    fn role_serialises_lowercase() {
112        let json = serde_json::to_string(&GraphRole::Assistant).unwrap();
113        assert_eq!(json, r#""assistant""#);
114    }
115}