ras_agent/application/
parse_output.rs1use 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}