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 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
28/// Count Edit and Write tool_use events in NDJSON stream output.
29fn 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
61/// Extract the last assistant text message from NDJSON stream output.
62fn 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
96/// Extract token usage from NDJSON stream by summing assistant message usage fields.
97fn 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); // take max (cumulative reported)
118                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
136/// Runner for MiniMax-via-Claude agents (delegates to ClaudeRunner for commands).
137/// Named `MiniMaxClaudeRunner` to distinguish from a future native MiniMax CLI runner.
138pub 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        // First try the standard Claude parsing (works if the model emits result events)
190        if let Ok(parsed) = self.inner.parse_response(raw) {
191            return Ok(parsed);
192        }
193
194        // MiniMax models often don't emit "type":"result" events.
195        // Build a response from the stream content instead.
196        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        // Status is always "done" — the response handler checks the worktree
203        // for actual code changes and re-routes if needed.
204        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        // Try standard Claude extraction first
238        if let Ok(text) = self.inner.extract_text(raw)
239            && !text.is_empty()
240        {
241            return Ok(text);
242        }
243
244        // Fall back to extracting last assistant text from stream
245        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        // No result event, just assistant messages
294        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}