Skip to main content

bob_runtime/
action.rs

1//! # Action Parser
2//!
3//! Action parser — extracts [`AgentAction`] from raw LLM text output.
4//!
5//! ## Overview
6//!
7//! The parser handles the conversion from raw LLM text output to structured
8//! [`AgentAction`] enum variants. It supports:
9//!
10//! - Plain JSON objects
11//! - JSON wrapped in markdown code fences (`` ```json ``)
12//! - Automatic whitespace trimming
13//!
14//! ## Action Types
15//!
16//! The parser recognizes three action types:
17//!
18//! - **`final`**: Final response to the user
19//! - **`tool_call`**: Request to execute a tool
20//! - **`ask_user`**: Request for user input
21//!
22//! ## Example
23//!
24//! ```rust,ignore
25//! use bob_runtime::action::parse_action;
26//! use bob_core::types::AgentAction;
27//!
28//! let json = r#"{"type": "final", "content": "Hello!"}"#;
29//! let action = parse_action(json)?;
30//!
31//! match action {
32//!     AgentAction::Final { content } => println!("Response: {}", content),
33//!     _ => println!("Other action"),
34//! }
35//! ```
36
37use bob_core::types::AgentAction;
38
39// ── Error Type ───────────────────────────────────────────────────────
40
41/// Errors that can occur when parsing an [`AgentAction`] from LLM output.
42#[derive(Debug, thiserror::Error)]
43pub enum ActionParseError {
44    /// The input could not be parsed as valid JSON.
45    #[error("invalid JSON: {0}")]
46    InvalidJson(#[from] serde_json::Error),
47
48    /// A required field is missing from the JSON object.
49    #[error("missing required field: {0}")]
50    MissingField(String),
51
52    /// The `type` field contains an unrecognised variant.
53    #[error("unknown action type: {0}")]
54    UnknownType(String),
55}
56
57// ── Public API ───────────────────────────────────────────────────────
58
59/// Parse a raw LLM text response into an [`AgentAction`].
60///
61/// Handles optional markdown code fences (`` ```json `` / `` ``` ``) and
62/// leading/trailing whitespace.
63pub fn parse_action(content: &str) -> Result<AgentAction, ActionParseError> {
64    let stripped = strip_code_fences(content);
65
66    // First pass: ensure it is valid JSON and is an object.
67    let value: serde_json::Value = serde_json::from_str(stripped)?;
68
69    let obj = value.as_object().ok_or_else(|| ActionParseError::MissingField("type".to_owned()))?;
70
71    // Check for the mandatory `type` discriminator.
72    let type_val = obj
73        .get("type")
74        .and_then(serde_json::Value::as_str)
75        .ok_or_else(|| ActionParseError::MissingField("type".to_owned()))?;
76
77    // Validate that `type` is a known variant before full deserialization so we
78    // can distinguish "unknown type" from other serde errors.
79    const KNOWN_TYPES: &[&str] = &["final", "tool_call", "ask_user"];
80    if !KNOWN_TYPES.contains(&type_val) {
81        return Err(ActionParseError::UnknownType(type_val.to_owned()));
82    }
83
84    // Full deserialization — will surface missing variant-specific fields as
85    // `InvalidJson` via the `From<serde_json::Error>` impl.
86    let action: AgentAction = serde_json::from_value(value)?;
87    Ok(action)
88}
89
90// ── Helpers ──────────────────────────────────────────────────────────
91
92/// Strip optional markdown code fences and surrounding whitespace.
93fn strip_code_fences(input: &str) -> &str {
94    let trimmed = input.trim();
95    // Handle ```json ... ``` and ``` ... ```
96    let without_opening = trimmed.strip_prefix("```json").or_else(|| trimmed.strip_prefix("```"));
97    match without_opening {
98        Some(rest) => rest.strip_suffix("```").unwrap_or(rest).trim(),
99        None => trimmed,
100    }
101}
102
103// ── Tests ────────────────────────────────────────────────────────────
104
105#[cfg(test)]
106mod tests {
107    use serde_json::json;
108
109    use super::*;
110
111    // ── Happy-path: each variant ─────────────────────────────────────
112
113    #[test]
114    fn parse_final_variant() {
115        let input = json!({"type": "final", "content": "Hello!"}).to_string();
116        let action = parse_action(&input);
117        assert!(
118            matches!(action.as_ref(), Ok(AgentAction::Final { content }) if content == "Hello!")
119        );
120    }
121
122    #[test]
123    fn parse_tool_call_variant() {
124        let input =
125            json!({"type": "tool_call", "name": "search", "arguments": {"q": "rust"}}).to_string();
126        let action = parse_action(&input);
127        assert!(matches!(
128            action.as_ref(),
129            Ok(AgentAction::ToolCall { name, arguments })
130                if name == "search" && *arguments == json!({"q": "rust"})
131        ));
132    }
133
134    #[test]
135    fn parse_ask_user_variant() {
136        let input = json!({"type": "ask_user", "question": "Which file?"}).to_string();
137        let action = parse_action(&input);
138        assert!(matches!(
139            action.as_ref(),
140            Ok(AgentAction::AskUser { question }) if question == "Which file?"
141        ));
142    }
143
144    // ── Error cases ──────────────────────────────────────────────────
145
146    #[test]
147    fn reject_missing_type_field() {
148        let input = json!({"content": "Hello!"}).to_string();
149        let err = parse_action(&input);
150        assert!(
151            matches!(err, Err(ActionParseError::MissingField(_))),
152            "expected MissingField, got {err:?}",
153        );
154    }
155
156    #[test]
157    fn reject_unknown_type() {
158        let input = json!({"type": "explode", "payload": 42}).to_string();
159        let err = parse_action(&input);
160        assert!(
161            matches!(err, Err(ActionParseError::UnknownType(_))),
162            "expected UnknownType, got {err:?}",
163        );
164    }
165
166    #[test]
167    fn reject_non_json_text() {
168        let err = parse_action("this is not json");
169        assert!(
170            matches!(err, Err(ActionParseError::InvalidJson(_))),
171            "expected InvalidJson, got {err:?}",
172        );
173    }
174
175    #[test]
176    fn reject_missing_required_field_for_variant() {
177        // ToolCall requires `name` and `arguments`
178        let input = json!({"type": "tool_call", "name": "search"}).to_string();
179        let err = parse_action(&input);
180        assert!(
181            matches!(
182                err,
183                Err(ActionParseError::InvalidJson(_) | ActionParseError::MissingField(_))
184            ),
185            "expected InvalidJson or MissingField, got {err:?}",
186        );
187    }
188
189    // ── Edge cases ───────────────────────────────────────────────────
190
191    #[test]
192    fn handle_json_code_fence() {
193        let input = format!("```json\n{}\n```", json!({"type": "final", "content": "done"}));
194        let action = parse_action(&input);
195        assert!(matches!(action, Ok(AgentAction::Final { .. })));
196    }
197
198    #[test]
199    fn handle_plain_code_fence() {
200        let input = format!("```\n{}\n```", json!({"type": "ask_user", "question": "yes?"}));
201        let action = parse_action(&input);
202        assert!(matches!(action, Ok(AgentAction::AskUser { .. })));
203    }
204
205    #[test]
206    fn handle_extra_whitespace() {
207        let input = format!("  \n\n  {}  \n\n  ", json!({"type": "final", "content": "hi"}));
208        let action = parse_action(&input);
209        assert!(matches!(action, Ok(AgentAction::Final { .. })));
210    }
211}