Skip to main content

entelix_agents/
state.rs

1//! Shared agent state types used across the recipes.
2//!
3//! Each recipe carries its messages list around as `Vec<Message>` (the
4//! shape `ChatModel` consumes). The agent-state structs below add a
5//! handful of bookkeeping fields per recipe.
6
7use entelix_core::ir::{ContentPart, Message, Role};
8
9/// Extract the concatenated [`ContentPart::Text`] body of the most
10/// recent [`Role::Assistant`] message in `messages`. Returns `None`
11/// when no assistant message exists or every assistant message
12/// contains only non-text content (tool calls only, reasoning only).
13///
14/// Reasoning blocks (`ContentPart::Reasoning`) are excluded by design
15/// — they're a separate variant from `Text` so recipes can surface
16/// them independently; the "assistant text" accessor returns the
17/// user-facing reply text only.
18fn last_assistant_text(messages: &[Message]) -> Option<String> {
19    let assistant = messages.iter().rev().find(|m| m.role == Role::Assistant)?;
20    let mut buf = String::new();
21    for part in &assistant.content {
22        if let ContentPart::Text { text, .. } = part {
23            buf.push_str(text);
24        }
25    }
26    if buf.is_empty() { None } else { Some(buf) }
27}
28
29/// State for [`crate::create_chat_agent`] and the simplest single-turn
30/// recipes — just the conversation so far.
31#[derive(Clone, Debug, Default, PartialEq, Eq)]
32pub struct ChatState {
33    /// The conversation, oldest first. The agent appends one assistant
34    /// message per invocation.
35    pub messages: Vec<Message>,
36}
37
38impl ChatState {
39    /// Build a state with a single user message.
40    pub fn from_user(text: impl Into<String>) -> Self {
41        Self {
42            messages: vec![Message::user(text)],
43        }
44    }
45
46    /// Concatenated user-facing text of the most recent assistant
47    /// message. `None` when no assistant message exists or only
48    /// non-text content was emitted (tool calls only).
49    #[must_use]
50    pub fn last_assistant_text(&self) -> Option<String> {
51        last_assistant_text(&self.messages)
52    }
53}
54
55/// State for [`crate::create_react_agent`] — messages plus a step count
56/// to make traces easier to inspect.
57#[derive(Clone, Debug, Default, PartialEq, Eq)]
58pub struct ReActState {
59    /// The conversation, oldest first. Tool results are appended as
60    /// `Role::Tool` messages between assistant turns.
61    pub messages: Vec<Message>,
62    /// Number of (model + tool) round trips taken so far.
63    pub steps: usize,
64}
65
66impl ReActState {
67    /// Build a state with a single user message.
68    pub fn from_user(text: impl Into<String>) -> Self {
69        Self {
70            messages: vec![Message::user(text)],
71            steps: 0,
72        }
73    }
74
75    /// Concatenated user-facing text of the most recent assistant
76    /// message. `None` when no assistant message exists or only
77    /// non-text content was emitted (tool calls only).
78    #[must_use]
79    pub fn last_assistant_text(&self) -> Option<String> {
80        last_assistant_text(&self.messages)
81    }
82}
83
84/// State for [`crate::create_supervisor_agent`] — messages plus the
85/// last-active sub-agent identifier for traceability.
86#[derive(Clone, Debug, Default, PartialEq, Eq)]
87pub struct SupervisorState {
88    /// The conversation, oldest first.
89    pub messages: Vec<Message>,
90    /// Name of the agent whose output most recently appended to
91    /// `messages`. `None` until the supervisor first dispatches.
92    pub last_speaker: Option<String>,
93    /// Routing decision set by the supervisor's last router call.
94    /// Reset to `None` when a sub-agent dispatch returns and the
95    /// supervisor is about to make the next decision.
96    pub next_speaker: Option<crate::supervisor::SupervisorDecision>,
97}
98
99impl SupervisorState {
100    /// Build a state with a single user message.
101    pub fn from_user(text: impl Into<String>) -> Self {
102        Self {
103            messages: vec![Message::user(text)],
104            last_speaker: None,
105            next_speaker: None,
106        }
107    }
108
109    /// Concatenated user-facing text of the most recent assistant
110    /// message. `None` when no assistant message exists or only
111    /// non-text content was emitted (tool calls only).
112    #[must_use]
113    pub fn last_assistant_text(&self) -> Option<String> {
114        last_assistant_text(&self.messages)
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    fn assistant(parts: Vec<ContentPart>) -> Message {
123        Message::new(Role::Assistant, parts)
124    }
125
126    fn text(s: &str) -> ContentPart {
127        ContentPart::text(s)
128    }
129
130    #[test]
131    fn returns_none_when_no_assistant_message_exists() {
132        let state = ChatState::from_user("hi");
133        assert_eq!(state.last_assistant_text(), None);
134    }
135
136    #[test]
137    fn concatenates_every_text_part_of_the_last_assistant_message() {
138        // Multi-part assistant message — every Text part contributes.
139        let mut state = ChatState::from_user("hi");
140        state
141            .messages
142            .push(assistant(vec![text("first"), text(" "), text("second")]));
143        assert_eq!(state.last_assistant_text(), Some("first second".to_owned()));
144    }
145
146    #[test]
147    fn skips_non_text_content_parts() {
148        // Tool-use blocks interleaved with text — only Text parts
149        // accumulate, preserving in-order concatenation.
150        let mut state = ReActState::from_user("ask");
151        let tool_use = ContentPart::ToolUse {
152            id: "tu1".into(),
153            name: "calc".into(),
154            input: serde_json::json!({}),
155            provider_echoes: Vec::new(),
156        };
157        state
158            .messages
159            .push(assistant(vec![text("before"), tool_use, text("after")]));
160        assert_eq!(state.last_assistant_text(), Some("beforeafter".to_owned()));
161    }
162
163    #[test]
164    fn returns_none_when_last_assistant_message_has_no_text() {
165        // Assistant turn with only tool-use blocks — no user-facing
166        // text to surface.
167        let mut state = SupervisorState::from_user("ask");
168        let tool_use = ContentPart::ToolUse {
169            id: "tu1".into(),
170            name: "calc".into(),
171            input: serde_json::json!({}),
172            provider_echoes: Vec::new(),
173        };
174        state.messages.push(assistant(vec![tool_use]));
175        assert_eq!(state.last_assistant_text(), None);
176    }
177
178    #[test]
179    fn returns_text_from_most_recent_assistant_skipping_earlier_turns() {
180        // Multiple assistant turns; the helper returns the LAST one's
181        // text, not the first.
182        let mut state = ChatState::from_user("hi");
183        state.messages.push(assistant(vec![text("old")]));
184        state.messages.push(Message::user("follow-up"));
185        state.messages.push(assistant(vec![text("new")]));
186        assert_eq!(state.last_assistant_text(), Some("new".to_owned()));
187    }
188}