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    truncate_at_char_boundary, AgentError, AgentRunner, ParsedResponse, PermissionRules,
18    StreamEvent,
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                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
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.get("input_tokens").and_then(|v| v.as_u64()) {
107                total_input = total_input.max(inp); // take max (cumulative reported)
108                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
123/// Runner for MiniMax-via-Claude agents (delegates to ClaudeRunner for commands).
124/// Named `MiniMaxClaudeRunner` to distinguish from a future native MiniMax CLI runner.
125pub 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        // First try the standard Claude parsing (works if the model emits result events)
170        if let Ok(parsed) = self.inner.parse_response(raw) {
171            return Ok(parsed);
172        }
173
174        // MiniMax models often don't emit "type":"result" events.
175        // Build a response from the stream content instead.
176        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        // Status is always "done" — the response handler checks the worktree
183        // for actual code changes and re-routes if needed.
184        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        // Try standard Claude extraction first
218        if let Ok(text) = self.inner.extract_text(raw) {
219            if !text.is_empty() {
220                return Ok(text);
221            }
222        }
223
224        // Fall back to extracting last assistant text from stream
225        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        // No result event, just assistant messages
278        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}