orch/engine/runner/agents/
minimax.rs1use super::claude::ClaudeRunner;
16use super::{
17 truncate_at_char_boundary, AgentError, AgentRunner, ParsedResponse, PermissionRules,
18 StreamEvent,
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 if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
78 let trimmed = text.trim();
79 if !trimmed.is_empty() {
80 last_text = Some(trimmed.to_string());
81 }
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.get("input_tokens").and_then(|v| v.as_u64()) {
107 total_input = total_input.max(inp); found = true;
109 }
110 if let Some(out) = usage.get("output_tokens").and_then(|v| v.as_u64()) {
111 total_output = total_output.max(out);
112 found = true;
113 }
114 }
115 }
116 if found {
117 (Some(total_input), Some(total_output))
118 } else {
119 (None, None)
120 }
121}
122
123pub struct MiniMaxClaudeRunner {
126 inner: ClaudeRunner,
127}
128
129impl MiniMaxClaudeRunner {
130 pub fn new(binary: &str) -> Self {
131 Self {
132 inner: ClaudeRunner::new(binary),
133 }
134 }
135}
136
137impl AgentRunner for MiniMaxClaudeRunner {
138 #[cfg(test)]
139 fn name(&self) -> &str {
140 self.inner.name()
141 }
142
143 fn stream_events(&self, event: &serde_json::Value) -> Vec<StreamEvent> {
144 self.inner.stream_events(event)
145 }
146
147 fn router_transcript(&self) -> super::RouterTranscript {
148 self.inner.router_transcript()
149 }
150
151 fn build_command(
152 &self,
153 model: Option<&str>,
154 timeout_cmd: &str,
155 sys_file: &str,
156 msg_file: &str,
157 permissions: &PermissionRules,
158 ) -> String {
159 self.inner
160 .build_command(model, timeout_cmd, sys_file, msg_file, permissions)
161 }
162
163 fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError> {
164 let trimmed = raw.trim();
165 if trimmed.is_empty() {
166 return Err(AgentError::InvalidResponse { raw: String::new() });
167 }
168
169 if let Ok(parsed) = self.inner.parse_response(raw) {
171 return Ok(parsed);
172 }
173
174 let edit_count = count_edit_tools(trimmed);
177 let last_text = extract_last_assistant_text(trimmed);
178 let (input_tokens, output_tokens) = extract_stream_usage(trimmed);
179
180 let summary = last_text.unwrap_or_default();
181
182 let status = "done".to_string();
185
186 tracing::info!(
187 edit_count,
188 summary_len = summary.len(),
189 "minimax: built response from stream (no result event)"
190 );
191
192 let response = crate::parser::AgentResponse {
193 status,
194 summary: {
195 let end = truncate_at_char_boundary(&summary, 500);
196 summary[..end].to_string()
197 },
198 accomplished: vec![],
199 remaining: vec![],
200 files: vec![],
201 error: None,
202 input_tokens,
203 output_tokens,
204 learnings: vec![],
205 delegations: vec![],
206 };
207
208 Ok(ParsedResponse {
209 response,
210 input_tokens,
211 output_tokens,
212 duration_ms: None,
213 })
214 }
215
216 fn extract_text(&self, raw: &str) -> Result<String, AgentError> {
217 if let Ok(text) = self.inner.extract_text(raw) {
219 if !text.is_empty() {
220 return Ok(text);
221 }
222 }
223
224 extract_last_assistant_text(raw).ok_or_else(|| AgentError::InvalidResponse {
226 raw: raw.chars().take(300).collect(),
227 })
228 }
229
230 fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError {
231 self.inner.classify_error(exit_code, stdout, stderr)
232 }
233
234 fn router_command(
235 &self,
236 prompt: &str,
237 model: Option<&str>,
238 ) -> anyhow::Result<tokio::process::Command> {
239 self.inner.router_command(prompt, model)
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn count_edit_tools_finds_edits() {
249 let raw = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{}}]}}
250{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Edit","input":{}}]}}
251{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{}}]}}
252{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Grep","input":{}}]}}"#;
253 assert_eq!(count_edit_tools(raw), 2);
254 }
255
256 #[test]
257 fn count_edit_tools_zero_on_read_only() {
258 let raw = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{}}]}}
259{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Grep","input":{}}]}}"#;
260 assert_eq!(count_edit_tools(raw), 0);
261 }
262
263 #[test]
264 fn extract_last_text_gets_final_message() {
265 let raw = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"first message"}]}}
266{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{}}]}}
267{"type":"assistant","message":{"content":[{"type":"text","text":"final summary"}]}}"#;
268 assert_eq!(
269 extract_last_assistant_text(raw),
270 Some("final summary".to_string())
271 );
272 }
273
274 #[test]
275 fn minimax_parse_falls_back_to_stream() {
276 let runner = MiniMaxClaudeRunner::new("minimax");
277 let raw = r#"{"type":"system","subtype":"init","model":"claude-opus-4-6"}
279{"type":"assistant","message":{"content":[{"type":"text","text":"analyzing code"}],"model":"MiniMax-M2.7","usage":{"input_tokens":1000,"output_tokens":200}}}"#;
280 let result = runner.parse_response(raw);
281 assert!(result.is_ok());
282 let parsed = result.unwrap();
283 assert_eq!(parsed.response.status, "done");
284 assert!(parsed.response.summary.contains("analyzing code"));
285 }
286
287 #[test]
288 fn minimax_delegates_to_claude_when_result_exists() {
289 let runner = MiniMaxClaudeRunner::new("minimax");
290 let raw = r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"status\":\"done\",\"summary\":\"fixed it\"}","duration_ms":5000}"#;
291 let result = runner.parse_response(raw);
292 assert!(result.is_ok());
293 let parsed = result.unwrap();
294 assert_eq!(parsed.response.summary, "fixed it");
295 }
296}