Skip to main content

ras_agent/application/
parse_output.rs

1use ras_errors::AppError;
2use ras_llm::ChatResponse;
3
4use crate::domain::agent_output::{ActionInvocation, AgentBrain, AgentOutput};
5
6pub(crate) fn parse_agent_output(response: &ChatResponse) -> Result<AgentOutput, AppError> {
7    if let Some(content) = &response.content {
8        if let Ok(parsed) = serde_json::from_str::<AgentOutput>(content) {
9            return Ok(parsed);
10        }
11        let cleaned = strip_code_fence(content);
12        if cleaned.as_ptr() != content.as_ptr()
13            && let Ok(parsed) = serde_json::from_str::<AgentOutput>(cleaned)
14        {
15            return Ok(parsed);
16        }
17    }
18    Ok(AgentOutput {
19        current_state: AgentBrain {
20            evaluation_previous_goal: String::new(),
21            memory: String::new(),
22            next_goal: response.content.clone().unwrap_or_default(),
23        },
24        action: tool_calls_to_actions(&response.tool_calls),
25        plan: None,
26        current_plan_item: None,
27    })
28}
29
30fn tool_calls_to_actions(calls: &[ras_llm::ToolCall]) -> Vec<ActionInvocation> {
31    calls
32        .iter()
33        .map(|c| ActionInvocation {
34            name: ras_types::ActionName(c.name.clone().into()),
35            parameters: c.arguments.clone(),
36        })
37        .collect()
38}
39
40fn strip_code_fence(s: &str) -> &str {
41    let trimmed = s.trim();
42    let opens = ["```json", "```JSON", "```Json", "```"];
43    let after_open = opens
44        .iter()
45        .find_map(|tag| trimmed.strip_prefix(tag))
46        .unwrap_or(trimmed)
47        .trim_start_matches('\n')
48        .trim_start();
49    let body = after_open
50        .strip_suffix("```")
51        .unwrap_or(after_open)
52        .trim_end()
53        .trim_end_matches('\n');
54    let out = body.trim();
55    if out.as_ptr() == s.as_ptr() && out.len() == s.len() {
56        s
57    } else {
58        out
59    }
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use ras_llm::{FinishReason, Usage};
66
67    fn resp(content: &str) -> ChatResponse {
68        ChatResponse {
69            content: Some(content.into()),
70            tool_calls: vec![],
71            usage: Usage::default(),
72            model: "test".into(),
73            finish_reason: FinishReason::Stop,
74        }
75    }
76
77    const VALID: &str = r#"{"current_state":{"evaluation_previous_goal":"","memory":"","next_goal":"go"},"action":[{"name":"navigate","parameters":{"url":"https://example.com/"}}]}"#;
78
79    #[test]
80    fn unfenced_json_parses() {
81        let out = parse_agent_output(&resp(VALID)).expect("parse");
82        assert_eq!(out.action.len(), 1);
83    }
84
85    #[test]
86    fn fenced_with_json_tag_and_newline() {
87        let body = format!("```json\n{VALID}\n```");
88        let out = parse_agent_output(&resp(&body)).expect("parse");
89        assert_eq!(out.action.len(), 1);
90    }
91
92    #[test]
93    fn fenced_with_uppercase_json_tag() {
94        let body = format!("```JSON\n{VALID}\n```");
95        let out = parse_agent_output(&resp(&body)).expect("parse");
96        assert_eq!(out.action.len(), 1);
97    }
98
99    #[test]
100    fn fenced_with_titlecase_json_tag() {
101        let body = format!("```Json\n{VALID}\n```");
102        let out = parse_agent_output(&resp(&body)).expect("parse");
103        assert_eq!(out.action.len(), 1);
104    }
105
106    #[test]
107    fn fenced_without_language_tag() {
108        let body = format!("```\n{VALID}\n```");
109        let out = parse_agent_output(&resp(&body)).expect("parse");
110        assert_eq!(out.action.len(), 1);
111    }
112
113    #[test]
114    fn fenced_with_surrounding_whitespace() {
115        let body = format!("\n\n  ```json\n{VALID}\n```  \n\n");
116        let out = parse_agent_output(&resp(&body)).expect("parse");
117        assert_eq!(out.action.len(), 1);
118    }
119
120    #[test]
121    fn fenced_invalid_json_falls_back_to_empty_actions() {
122        let body = "```json\nnot json at all\n```";
123        let out = parse_agent_output(&resp(body)).expect("parse");
124        assert!(out.action.is_empty());
125    }
126
127    #[test]
128    fn unfenced_invalid_json_falls_back_to_empty_actions() {
129        let out = parse_agent_output(&resp("totally not json")).expect("parse");
130        assert!(out.action.is_empty());
131    }
132
133    #[test]
134    fn null_brain_fields_parse_as_empty_strings() {
135        let body = r#"{"current_state":{"evaluation_previous_goal":null,"memory":null,"next_goal":"go"},"action":[{"name":"navigate","parameters":{"url":"https://example.com/"}}]}"#;
136        let out = parse_agent_output(&resp(body)).expect("parse");
137        assert_eq!(
138            out.action.len(),
139            1,
140            "action should not be lost to null brain fields"
141        );
142        assert_eq!(out.current_state.evaluation_previous_goal, "");
143        assert_eq!(out.current_state.memory, "");
144        assert_eq!(out.current_state.next_goal, "go");
145    }
146
147    #[test]
148    fn non_string_brain_fields_coerce_to_string() {
149        let body = r#"{"current_state":{"evaluation_previous_goal":"ok","memory":[],"next_goal":"go"},"action":[{"name":"navigate","parameters":{"url":"https://example.com/"}}]}"#;
150        let out = parse_agent_output(&resp(body)).expect("parse");
151        assert_eq!(
152            out.action.len(),
153            1,
154            "action should not be lost to non-string memory field"
155        );
156        assert_eq!(out.current_state.memory, "[]");
157    }
158
159    #[test]
160    fn missing_brain_fields_default_to_empty() {
161        let body = r#"{"current_state":{"next_goal":"go"},"action":[{"name":"navigate","parameters":{"url":"https://example.com/"}}]}"#;
162        let out = parse_agent_output(&resp(body)).expect("parse");
163        assert_eq!(out.action.len(), 1);
164        assert_eq!(out.current_state.evaluation_previous_goal, "");
165        assert_eq!(out.current_state.memory, "");
166    }
167}