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