orch/engine/runner/agents/
minimax.rs1use super::claude::ClaudeRunner;
16use super::{
17 AgentError, AgentRunner, ParsedResponse, PermissionRules, StreamEvent,
18 truncate_at_char_boundary,
19};
20
21fn count_edit_tools(raw: &str) -> usize {
23 let mut count = 0;
24 for line in raw.lines() {
25 let line = line.trim();
26 if line.is_empty() {
27 continue;
28 }
29 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
30 continue;
31 };
32 if val.get("type").and_then(|v| v.as_str()) != Some("assistant") {
33 continue;
34 }
35 let Some(content) = val
36 .get("message")
37 .and_then(|m| m.get("content"))
38 .and_then(|c| c.as_array())
39 else {
40 continue;
41 };
42 for item in content {
43 if item.get("type").and_then(|v| v.as_str()) == Some("tool_use") {
44 let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
45 if name == "Edit" || name == "Write" || name == "NotebookEdit" {
46 count += 1;
47 }
48 }
49 }
50 }
51 count
52}
53
54fn extract_last_assistant_text(raw: &str) -> Option<String> {
56 let mut last_text = None;
57 for line in raw.lines() {
58 let line = line.trim();
59 if line.is_empty() {
60 continue;
61 }
62 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
63 continue;
64 };
65 if val.get("type").and_then(|v| v.as_str()) != Some("assistant") {
66 continue;
67 }
68 let Some(content) = val
69 .get("message")
70 .and_then(|m| m.get("content"))
71 .and_then(|c| c.as_array())
72 else {
73 continue;
74 };
75 for item in content {
76 if item.get("type").and_then(|v| v.as_str()) == Some("text")
77 && let Some(text) = item.get("text").and_then(|v| v.as_str())
78 {
79 let trimmed = text.trim();
80 if !trimmed.is_empty() {
81 last_text = Some(trimmed.to_string());
82 }
83 }
84 }
85 }
86 last_text
87}
88
89fn extract_stream_usage(raw: &str) -> (Option<u64>, Option<u64>) {
91 let mut total_input: u64 = 0;
92 let mut total_output: u64 = 0;
93 let mut found = false;
94 for line in raw.lines() {
95 let line = line.trim();
96 if line.is_empty() {
97 continue;
98 }
99 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
100 continue;
101 };
102 if val.get("type").and_then(|v| v.as_str()) != Some("assistant") {
103 continue;
104 }
105 if let Some(usage) = val.get("message").and_then(|m| m.get("usage")) {
106 if let Some(inp) = usage
107 .get("input_tokens")
108 .and_then(serde_json::Value::as_u64)
109 {
110 total_input = total_input.max(inp); found = true;
112 }
113 if let Some(out) = usage
114 .get("output_tokens")
115 .and_then(serde_json::Value::as_u64)
116 {
117 total_output = total_output.max(out);
118 found = true;
119 }
120 }
121 }
122 if found {
123 (Some(total_input), Some(total_output))
124 } else {
125 (None, None)
126 }
127}
128
129pub struct MiniMaxClaudeRunner {
132 inner: ClaudeRunner,
133}
134
135impl std::fmt::Debug for MiniMaxClaudeRunner {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 f.debug_struct("MiniMaxClaudeRunner")
138 .finish_non_exhaustive()
139 }
140}
141
142impl MiniMaxClaudeRunner {
143 pub fn new(binary: &str) -> Self {
144 Self {
145 inner: ClaudeRunner::new(binary),
146 }
147 }
148}
149
150impl AgentRunner for MiniMaxClaudeRunner {
151 #[cfg(test)]
152 fn name(&self) -> &str {
153 self.inner.name()
154 }
155
156 fn stream_events(&self, event: &serde_json::Value) -> Vec<StreamEvent> {
157 self.inner.stream_events(event)
158 }
159
160 fn router_transcript(&self) -> super::RouterTranscript {
161 self.inner.router_transcript()
162 }
163
164 fn build_command(
165 &self,
166 model: Option<&str>,
167 timeout_cmd: &str,
168 sys_file: &str,
169 msg_file: &str,
170 permissions: &PermissionRules,
171 ) -> String {
172 self.inner
173 .build_command(model, timeout_cmd, sys_file, msg_file, permissions)
174 }
175
176 fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError> {
177 let trimmed = raw.trim();
178 if trimmed.is_empty() {
179 return Err(AgentError::InvalidResponse { raw: String::new() });
180 }
181
182 if let Ok(parsed) = self.inner.parse_response(raw) {
184 return Ok(parsed);
185 }
186
187 let edit_count = count_edit_tools(trimmed);
190 let last_text = extract_last_assistant_text(trimmed);
191 let (input_tokens, output_tokens) = extract_stream_usage(trimmed);
192
193 let summary = last_text.unwrap_or_default();
194
195 let status = "done".to_string();
198
199 tracing::info!(
200 edit_count,
201 summary_len = summary.len(),
202 "minimax: built response from stream (no result event)"
203 );
204
205 let response = crate::parser::AgentResponse {
206 status,
207 summary: {
208 let end = truncate_at_char_boundary(&summary, 500);
209 summary[..end].to_string()
210 },
211 accomplished: vec![],
212 remaining: vec![],
213 files: vec![],
214 error: None,
215 input_tokens,
216 output_tokens,
217 learnings: vec![],
218 delegations: vec![],
219 };
220
221 Ok(ParsedResponse {
222 response,
223 input_tokens,
224 output_tokens,
225 duration_ms: None,
226 })
227 }
228
229 fn extract_text(&self, raw: &str) -> Result<String, AgentError> {
230 if let Ok(text) = self.inner.extract_text(raw)
232 && !text.is_empty()
233 {
234 return Ok(text);
235 }
236
237 extract_last_assistant_text(raw).ok_or_else(|| AgentError::InvalidResponse {
239 raw: raw.chars().take(300).collect(),
240 })
241 }
242
243 fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError {
244 self.inner.classify_error(exit_code, stdout, stderr)
245 }
246
247 fn router_command(
248 &self,
249 prompt: &str,
250 model: Option<&str>,
251 ) -> anyhow::Result<tokio::process::Command> {
252 self.inner.router_command(prompt, model)
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn count_edit_tools_finds_edits() {
262 let raw = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{}}]}}
263{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Edit","input":{}}]}}
264{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{}}]}}
265{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Grep","input":{}}]}}"#;
266 assert_eq!(count_edit_tools(raw), 2);
267 }
268
269 #[test]
270 fn count_edit_tools_zero_on_read_only() {
271 let raw = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{}}]}}
272{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Grep","input":{}}]}}"#;
273 assert_eq!(count_edit_tools(raw), 0);
274 }
275
276 #[test]
277 fn extract_last_text_gets_final_message() {
278 let raw = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"first message"}]}}
279{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{}}]}}
280{"type":"assistant","message":{"content":[{"type":"text","text":"final summary"}]}}"#;
281 assert_eq!(
282 extract_last_assistant_text(raw),
283 Some("final summary".to_string())
284 );
285 }
286
287 #[test]
288 fn minimax_parse_falls_back_to_stream() {
289 let runner = MiniMaxClaudeRunner::new("minimax");
290 let raw = r#"{"type":"system","subtype":"init","model":"claude-opus-4-6"}
292{"type":"assistant","message":{"content":[{"type":"text","text":"analyzing code"}],"model":"MiniMax-M2.7","usage":{"input_tokens":1000,"output_tokens":200}}}"#;
293 let result = runner.parse_response(raw);
294 assert!(result.is_ok());
295 let parsed = result.unwrap();
296 assert_eq!(parsed.response.status, "done");
297 assert!(parsed.response.summary.contains("analyzing code"));
298 }
299
300 #[test]
301 fn minimax_delegates_to_claude_when_result_exists() {
302 let runner = MiniMaxClaudeRunner::new("minimax");
303 let raw = r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"status\":\"done\",\"summary\":\"fixed it\"}","duration_ms":5000}"#;
304 let result = runner.parse_response(raw);
305 assert!(result.is_ok());
306 let parsed = result.unwrap();
307 assert_eq!(parsed.response.summary, "fixed it");
308 }
309}