fix(agents): parse MiniMax M2.5 XML tool calls instead of silently stripping#46401
fix(agents): parse MiniMax M2.5 XML tool calls instead of silently stripping#46401Br1an67 wants to merge 1 commit into
Conversation
Greptile SummaryThis PR fixes a long-standing silent data-loss bug: MiniMax M2.5 tool calls embedded as XML in text content blocks are now parsed into structured Key observations:
Confidence Score: 4/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/agents/pi-embedded-utils.ts
Line: 48-51
Comment:
**Missing XML entity decoding for parameter values**
The regex parser captures parameter content verbatim without decoding XML entities. Well-formed XML requires `&` to be encoded as `&`, `<` as `<`, `>` as `>`, etc. If MiniMax M2.5 emits compliant XML, a command like `echo "hello & world"` would appear in the raw XML as `echo "hello & world"`, and that encoded string would be forwarded to the tool unchanged.
The test at line 679 of the test file explicitly asserts this pass-through behavior — confirming the issue is known but not resolved. If MiniMax does encode entities (even partially), tool invocations with `&`, `<`, or `"` in parameter values will silently receive the wrong input.
Consider adding a minimal entity decode step:
```typescript
const HTML_ENTITIES: Record<string, string> = {
"&": "&",
"<": "<",
">": ">",
""": '"',
"'": "'",
};
function decodeXmlEntities(value: string): string {
return value.replace(/&(?:amp|lt|gt|quot|apos);/gi, (entity) => HTML_ENTITIES[entity.toLowerCase()] ?? entity);
}
```
Then apply it when storing the parameter value:
```typescript
input[paramMatch[1]] = decodeXmlEntities(paramMatch[2]!);
```
If MiniMax is confirmed to never encode entities, document that assumption explicitly in the JSDoc so future maintainers don't have to rediscover it.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/agents/pi-embedded-utils.ts
Line: 109-123
Comment:
**Interleaved prose order is not preserved across multiple tool calls**
When a single text block contains prose _between_ multiple `<invoke>` blocks — e.g.:
```
Let me read the file.
<invoke name="Read">...</invoke></minimax:tool_call>
Now let me run the command.
<invoke name="Bash">...</invoke></minimax:tool_call>
```
`stripMinimaxToolCallXml` collapses all prose into a single string (`"Let me read the file.\nNow let me run the command."`), which is pushed as one text block _before_ all `toolUse` blocks. The interleaved ordering ("read → prose → bash") is lost; downstream consumers will see all prose up-front followed by both tool calls.
This matches how `promoteThinkingTagsToBlocks` works (it preserves order within a single text block), but it is different from what a streaming model would produce. If ordering matters for the tool-dispatch pipeline, the prose segments should be split and interleaved between the `toolUse` blocks. If it intentionally doesn't matter, a brief comment would help future readers.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/agents/pi-embedded-utils.ts
Line: 18
Comment:
**Module-level mutable counter shared across all tests and all runtime invocations**
`minimaxToolCallCounter` is a module-level variable. In a long-running process it is monotonically increasing, which guarantees uniqueness — but:
1. **Concurrent tests**: `vitest` can run test files in parallel worker threads. Each worker gets its own module instance so the counter resets per worker, meaning two workers can produce the same `toolu_minimax_1` ID for the same session/message. This won't cause test failures (tests only check uniqueness _within_ a call), but if IDs are stored and later correlated they could collide.
2. **Readability**: A counter makes IDs predictable/sequential, but `crypto.randomUUID()` (or even `Date.now() + Math.random()`) would make IDs truly unique without shared state.
Consider:
```typescript
id: `toolu_minimax_${Date.now()}_${Math.random().toString(36).slice(2)}`,
```
or using `crypto.randomUUID()` if available in the target runtime.
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: 81c1247 |
| const paramRe = /<parameter\s+name=["']([^"']+)["'][^>]*>([\s\S]*?)<\/parameter>/gi; | ||
| for (const paramMatch of body.matchAll(paramRe)) { | ||
| input[paramMatch[1]] = paramMatch[2]!; | ||
| } |
There was a problem hiding this comment.
Missing XML entity decoding for parameter values
The regex parser captures parameter content verbatim without decoding XML entities. Well-formed XML requires & to be encoded as &, < as <, > as >, etc. If MiniMax M2.5 emits compliant XML, a command like echo "hello & world" would appear in the raw XML as echo "hello & world", and that encoded string would be forwarded to the tool unchanged.
The test at line 679 of the test file explicitly asserts this pass-through behavior — confirming the issue is known but not resolved. If MiniMax does encode entities (even partially), tool invocations with &, <, or " in parameter values will silently receive the wrong input.
Consider adding a minimal entity decode step:
const HTML_ENTITIES: Record<string, string> = {
"&": "&",
"<": "<",
">": ">",
""": '"',
"'": "'",
};
function decodeXmlEntities(value: string): string {
return value.replace(/&(?:amp|lt|gt|quot|apos);/gi, (entity) => HTML_ENTITIES[entity.toLowerCase()] ?? entity);
}Then apply it when storing the parameter value:
input[paramMatch[1]] = decodeXmlEntities(paramMatch[2]!);If MiniMax is confirmed to never encode entities, document that assumption explicitly in the JSDoc so future maintainers don't have to rediscover it.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-embedded-utils.ts
Line: 48-51
Comment:
**Missing XML entity decoding for parameter values**
The regex parser captures parameter content verbatim without decoding XML entities. Well-formed XML requires `&` to be encoded as `&`, `<` as `<`, `>` as `>`, etc. If MiniMax M2.5 emits compliant XML, a command like `echo "hello & world"` would appear in the raw XML as `echo "hello & world"`, and that encoded string would be forwarded to the tool unchanged.
The test at line 679 of the test file explicitly asserts this pass-through behavior — confirming the issue is known but not resolved. If MiniMax does encode entities (even partially), tool invocations with `&`, `<`, or `"` in parameter values will silently receive the wrong input.
Consider adding a minimal entity decode step:
```typescript
const HTML_ENTITIES: Record<string, string> = {
"&": "&",
"<": "<",
">": ">",
""": '"',
"'": "'",
};
function decodeXmlEntities(value: string): string {
return value.replace(/&(?:amp|lt|gt|quot|apos);/gi, (entity) => HTML_ENTITIES[entity.toLowerCase()] ?? entity);
}
```
Then apply it when storing the parameter value:
```typescript
input[paramMatch[1]] = decodeXmlEntities(paramMatch[2]!);
```
If MiniMax is confirmed to never encode entities, document that assumption explicitly in the JSDoc so future maintainers don't have to rediscover it.
How can I resolve this? If you propose a fix, please make it concise.| // Strip XML from text and keep any remaining prose. | ||
| const cleaned = stripMinimaxToolCallXml(text).trim(); | ||
| if (cleaned) { | ||
| next.push({ type: "text", text: cleaned }); | ||
| } | ||
|
|
||
| // Insert structured toolUse blocks. | ||
| for (const tc of toolCalls) { | ||
| next.push({ | ||
| type: "toolUse", | ||
| id: tc.id, | ||
| name: tc.name, | ||
| input: tc.input, | ||
| } as unknown as (typeof next)[number]); | ||
| } |
There was a problem hiding this comment.
Interleaved prose order is not preserved across multiple tool calls
When a single text block contains prose between multiple <invoke> blocks — e.g.:
Let me read the file.
<invoke name="Read">...</invoke></minimax:tool_call>
Now let me run the command.
<invoke name="Bash">...</invoke></minimax:tool_call>
stripMinimaxToolCallXml collapses all prose into a single string ("Let me read the file.\nNow let me run the command."), which is pushed as one text block before all toolUse blocks. The interleaved ordering ("read → prose → bash") is lost; downstream consumers will see all prose up-front followed by both tool calls.
This matches how promoteThinkingTagsToBlocks works (it preserves order within a single text block), but it is different from what a streaming model would produce. If ordering matters for the tool-dispatch pipeline, the prose segments should be split and interleaved between the toolUse blocks. If it intentionally doesn't matter, a brief comment would help future readers.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-embedded-utils.ts
Line: 109-123
Comment:
**Interleaved prose order is not preserved across multiple tool calls**
When a single text block contains prose _between_ multiple `<invoke>` blocks — e.g.:
```
Let me read the file.
<invoke name="Read">...</invoke></minimax:tool_call>
Now let me run the command.
<invoke name="Bash">...</invoke></minimax:tool_call>
```
`stripMinimaxToolCallXml` collapses all prose into a single string (`"Let me read the file.\nNow let me run the command."`), which is pushed as one text block _before_ all `toolUse` blocks. The interleaved ordering ("read → prose → bash") is lost; downstream consumers will see all prose up-front followed by both tool calls.
This matches how `promoteThinkingTagsToBlocks` works (it preserves order within a single text block), but it is different from what a streaming model would produce. If ordering matters for the tool-dispatch pipeline, the prose segments should be split and interleaved between the `toolUse` blocks. If it intentionally doesn't matter, a brief comment would help future readers.
How can I resolve this? If you propose a fix, please make it concise.| id: string; | ||
| } | ||
|
|
||
| let minimaxToolCallCounter = 0; |
There was a problem hiding this comment.
Module-level mutable counter shared across all tests and all runtime invocations
minimaxToolCallCounter is a module-level variable. In a long-running process it is monotonically increasing, which guarantees uniqueness — but:
- Concurrent tests:
vitestcan run test files in parallel worker threads. Each worker gets its own module instance so the counter resets per worker, meaning two workers can produce the sametoolu_minimax_1ID for the same session/message. This won't cause test failures (tests only check uniqueness within a call), but if IDs are stored and later correlated they could collide. - Readability: A counter makes IDs predictable/sequential, but
crypto.randomUUID()(or evenDate.now() + Math.random()) would make IDs truly unique without shared state.
Consider:
id: `toolu_minimax_${Date.now()}_${Math.random().toString(36).slice(2)}`,or using crypto.randomUUID() if available in the target runtime.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-embedded-utils.ts
Line: 18
Comment:
**Module-level mutable counter shared across all tests and all runtime invocations**
`minimaxToolCallCounter` is a module-level variable. In a long-running process it is monotonically increasing, which guarantees uniqueness — but:
1. **Concurrent tests**: `vitest` can run test files in parallel worker threads. Each worker gets its own module instance so the counter resets per worker, meaning two workers can produce the same `toolu_minimax_1` ID for the same session/message. This won't cause test failures (tests only check uniqueness _within_ a call), but if IDs are stored and later correlated they could collide.
2. **Readability**: A counter makes IDs predictable/sequential, but `crypto.randomUUID()` (or even `Date.now() + Math.random()`) would make IDs truly unique without shared state.
Consider:
```typescript
id: `toolu_minimax_${Date.now()}_${Math.random().toString(36).slice(2)}`,
```
or using `crypto.randomUUID()` if available in the target runtime.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 81c1247c9a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const invokeRe = /<invoke\s+name=["']([^"']+)["'][^>]*>([\s\S]*?)<\/invoke>/gi; | ||
|
|
||
| for (const match of text.matchAll(invokeRe)) { |
There was a problem hiding this comment.
Scope invoke parsing to minimax tool_call segments
This regex parses every <invoke ...> in the entire text as soon as minimax:tool_call appears anywhere, so explanatory/code-sample XML can be promoted into executable toolUse blocks even when it is not an actual tool invocation. In practice, a reply that discusses MiniMax XML format (or echoes user-provided XML) can accidentally trigger real tool execution; parsing should be limited to explicit <minimax:tool_call>...</minimax:tool_call> sections (or another strict envelope) before promoting blocks.
Useful? React with 👍 / 👎.
| for (const paramMatch of body.matchAll(paramRe)) { | ||
| input[paramMatch[1]] = paramMatch[2]!; |
There was a problem hiding this comment.
Decode XML entities before assigning tool parameters
Parameter values are copied verbatim from XML text, so escaped entities remain encoded in tool inputs (for example & instead of &). If MiniMax emits valid XML escaping, tool arguments become semantically wrong (&&, <, > and similar tokens are corrupted), which can break command/tool behavior despite the call being otherwise parsed successfully.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2cfbd1714c
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
|
||
| for (const invokeMatch of text.matchAll(invokeReWalk)) { | ||
| const matchStart = invokeMatch.index!; | ||
| const prose = text.slice(cursor, matchStart).replace(/<\/?minimax:tool_call>/gi, "").trim(); |
There was a problem hiding this comment.
Strip non-dispatched
<invoke> tags from emitted prose
Only <minimax:tool_call> wrappers are removed from prose, so when a text block contains at least one parseable <invoke name="..."> plus any other <invoke> fragment that is not promoted, that leftover XML is emitted as user-visible text. For example, Before<invoke>debug</invoke><minimax:tool_call><invoke name="Bash">...</invoke></minimax:tool_call> will keep <invoke>debug</invoke> after promotion, and extractAssistantText will no longer strip it because stripMinimaxToolCallXml is gated on minimax:tool_call being present. This is a regression from the previous strip-only path that removed all <invoke> blocks in MiniMax-tagged text.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 81c1247c9a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| @@ -268,6 +269,7 @@ export function handleMessageEnd( | |||
| return; | |||
| } | |||
| promoteThinkingTagsToBlocks(assistantMessage); | |||
| promoteMinimaxToolCallsToBlocks(assistantMessage); | |||
There was a problem hiding this comment.
Parse MiniMax tool XML before turn completion
Calling promoteMinimaxToolCallsToBlocks only in handleMessageEnd means XML is converted after the assistant turn has already completed, so the newly created toolUse blocks are observational only and are not dispatched through the normal tool-execution path; on later turns this can be misread as dangling tool calls and repaired into synthetic missing toolResult errors during history sanitization. This triggers whenever MiniMax emits XML tool-call text, so promotion needs to happen in the pre-dispatch message path (or not emit toolUse blocks at this stage).
Useful? React with 👍 / 👎.
dd66e4c to
81c1247
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dd66e4cdd3
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| * left untouched and the existing strip-only path in extractAssistantText | ||
| * will clean it up. | ||
| */ | ||
| export function promoteMinimaxToolCallsToBlocks(message: AssistantMessage): void { |
There was a problem hiding this comment.
Wire MiniMax tool-call promotion into runtime message handling
This new promotion path is currently dead code: a repo-wide usage check only finds promoteMinimaxToolCallsToBlocks in this file and its tests, so assistant turns never run it in production. As a result, MiniMax XML tool calls are still just text and then get removed by the existing text sanitizer path, meaning the commit does not actually dispatch the tool calls it was meant to preserve.
Useful? React with 👍 / 👎.
|
|
||
| const toolCalls: ParsedMinimaxToolCall[] = []; | ||
| // Only parse <invoke> blocks within <minimax:tool_call>...</minimax:tool_call> segments | ||
| const segmentRe = /<minimax:tool_call>([\s\S]*?)<\/minimax:tool_call>/gi; |
There was a problem hiding this comment.
Accept MiniMax invocations without an opening tool_call tag
The parser only extracts <invoke> blocks from fully wrapped <minimax:tool_call>...</minimax:tool_call> segments, but this codebase’s MiniMax fixtures (including several in this same test file) commonly use ...<invoke ...></invoke></minimax:tool_call> without the opening tag. In that format parseMinimaxToolCallXml returns no tool calls, so promotion silently falls back and the invocation is stripped instead of executed.
Useful? React with 👍 / 👎.
…ripping Add parseMinimaxToolCallXml() to extract structured tool call data from MiniMax XML embedded in text content blocks. Add promoteMinimaxToolCallsToBlocks() which transforms text blocks containing MiniMax XML into proper toolUse content blocks on the assistant message, following the same in-place mutation pattern as promoteThinkingTagsToBlocks. The promotion runs before extractAssistantText() so tool calls are dispatched as structured blocks rather than silently dropped. Falls back to the existing strip-only behavior when parsing yields no results. Fixes openclaw#41839 Co-authored-by: Copilot <[email protected]>
|
Closing to manage active PR count. Will reopen when slot is available. |
Summary
Fixes #41839 — MiniMax M2.5 tool calls were silently stripped from assistant messages instead of being dispatched as structured tool calls.
Problem
MiniMax M2.5 embeds tool invocations as XML in text content blocks instead of emitting standard
toolUseblocks:The existing
stripMinimaxToolCallXml()removed this XML from displayed text but never parsed or dispatched the tool calls — they were silently dropped.Solution
New functions in
pi-embedded-utils.ts:parseMinimaxToolCallXml(text)— Extracts<invoke name="..."><parameter name="...">value</parameter></invoke>blocks from text containingminimax:tool_callmarkers. Returns an array of{ name, input, id }objects withtoolu_minimax_prefixed IDs.promoteMinimaxToolCallsToBlocks(message)— Transforms assistant message content in-place (same pattern aspromoteThinkingTagsToBlocks): iterates text blocks, parses XML tool calls, strips the XML from remaining text, and inserts structured{ type: "toolUse", id, name, input }content blocks.Integration:
Called in
pi-embedded-subscribe.handlers.messages.tsright afterpromoteThinkingTagsToBlocks()and beforeextractAssistantText(), ensuring tool calls are available as structured blocks before text sanitization runs.Backward compatibility:
Falls back gracefully — if parsing yields no tool calls (e.g.
<invoke>withoutnameattribute), the text block is left untouched and the existingstripMinimaxToolCallXml()insideextractAssistantText()still cleans stray tags.Test plan
parseMinimaxToolCallXml(empty input, single/multiple invocations, multiple params, extra attributes, missing name, unique IDs, special characters)promoteMinimaxToolCallsToBlocks(basic conversion, tool-only text, multiple blocks, non-minimax blocks preserved, existing toolUse preserved, fallback on missing name, null content entries)extractAssistantTexttests continue to passNote
This PR was authored with assistance from GitHub Copilot.