Skip to main content

orch/engine/runner/agents/
minimax.rs

1//! MiniMax agent runner — extends the Claude-compatible runner.
2//!
3//! MiniMax uses the `claude` CLI via a shell wrapper that sets
4//! `ANTHROPIC_BASE_URL` to the MiniMax API. The underlying model
5//! (MiniMax-M2.7) has different output behavior from Claude:
6//!
7//! - **No `"type":"result"` event** — the stream-json output ends with
8//!   an `assistant` message, not a proper result envelope.
9//! - **`stop_reason: null`** — the model doesn't set stop_reason.
10//! - **Analysis-heavy** — tends to do many Read/Grep calls before editing.
11//!
12//! This runner overrides `parse_response` to handle these differences
13//! while delegating command building and routing to `ClaudeRunner`.
14
15use super::claude::ClaudeRunner;
16use super::{
17    AgentError, AgentRunner, ParsedResponse, PermissionRules, StreamEvent,
18    truncate_at_char_boundary,
19};
20
21/// Count Edit and Write tool_use events in NDJSON stream output.
22fn 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
54/// Extract the last assistant text message from NDJSON stream output.
55fn 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
89/// Extract token usage from NDJSON stream by summing assistant message usage fields.
90fn 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); // take max (cumulative reported)
111                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
129/// Runner for MiniMax-via-Claude agents (delegates to ClaudeRunner for commands).
130/// Named `MiniMaxClaudeRunner` to distinguish from a future native MiniMax CLI runner.
131pub 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        // First try the standard Claude parsing (works if the model emits result events)
183        if let Ok(parsed) = self.inner.parse_response(raw) {
184            return Ok(parsed);
185        }
186
187        // MiniMax models often don't emit "type":"result" events.
188        // Build a response from the stream content instead.
189        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        // Status is always "done" — the response handler checks the worktree
196        // for actual code changes and re-routes if needed.
197        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        // Try standard Claude extraction first
231        if let Ok(text) = self.inner.extract_text(raw)
232            && !text.is_empty()
233        {
234            return Ok(text);
235        }
236
237        // Fall back to extracting last assistant text from stream
238        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        // No result event, just assistant messages
291        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}