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}