gateway: a tool call GLM writes into its text in its own format is made, and Codex's exec gets its code as a custom tool call (#906, nullburn)

GLM-5.3-Flash on a local vLLM, used from Codex Desktop through magpie's Responses endpoint, wrote its calls into its text as GLM's template does: <tool_call>exec<arg_key>input</arg_key><arg_value>…</arg_value></tool_call>. magpie held text from <tool_call> on but read only Hermes' JSON after it, so the block went to Codex as text and the turn ended. parseTextCalls now reads GLM's form when what follows <tool_call> is not JSON: the tool's name, then each <arg_key>/<arg_value> pair, a value that is JSON (number, object, list, boolean) taken as JSON, else the string it says. As before, every block must name a tool the request offered, else the whole reply stays the text it was. It works wherever the text is read for calls: Chat's relay, the translated Chat, Responses and Anthropic paths, streamed and whole, and a plugin's model; a Responses custom tool (Codex's exec) becomes a custom_tool_call carrying the input.

Tested end to end in a sandbox HOME: a fake vLLM streaming GLM's markup split mid-tag, added as a provider, magpie serve on a spare port, and a Responses client offering exec as a custom tool and update_plan: streamed and whole, the reply came back as the text before the block and a custom_tool_call exec whose input is the code. TestGLMTextCallCustomTool (stream split mid-tag, streamed and whole), TestGLMTextCallFunctionTool (JSON values kept, two calls), TestGLMTextCallChatAndMessages and TestPluginGLMTextCall are new and fail without the change; TestGLMTextCallUnofferedLeftAsText (an unoffered tool stays text) passes with and without it. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass (TestClientGoneRecordedAsCanceled fails now and then on main without this change too).
This commit is contained in:
yetone committed 2026-10-05 21:34:38 +08:00
1 parent 2a57b7bb60
commit 307f413687
2 files changed
+237 -1

No files matched your search

+54 -1
View File
@@ -12,7 +12,9 @@ import (
// A model deep into a long conversation may write its tool calls into its
// text instead of making them (#823, Dazzle-sys: DeepSeek through a group,
// in Pi, ~700k tokens in): Hermes' <tool_call>{"name": …, "arguments": …}
// </tool_call>, or DeepSeek's own <|DSML|invoke name="…"> with its
// </tool_call>, GLM's <tool_call>name<arg_key>…</arg_key><arg_value>…
// </arg_value></tool_call> (#906), or DeepSeek's own <|DSML|invoke
// name="…"> with its
// parameters, often with the other's closing tags strewn after it. The
// agent got it as text, ran nothing and ended the turn; told to go on, the
// model read its own text-written call back and wrote the next one the
@@ -122,6 +124,10 @@ func parseTextCalls(s string, names map[string]bool) (calls []writtenCall, rest
continue
}
c, n, good := hermesCall(s[h+len("<tool_call>"):], names)
if !good && !strings.HasPrefix(strings.TrimLeft(s[h+len("<tool_call>"):], " \t\r\n"), "{") {
// GLM's own: the name, then its arguments in pairs (#906)
c, n, good = glmCall(s[h+len("<tool_call>"):], names)
}
if !good {
return nil, "", false
}
@@ -203,6 +209,53 @@ func hermesCall(s string, names map[string]bool) (writtenCall, int, bool) {
return writtenCall{Name: v.Name, Args: args}, n, true
}
var (
glmPair = regexp.MustCompile(`(?s)^\s*<arg_key>(.*?)</arg_key>\s*<arg_value>(.*?)</arg_value>`)
glmName = regexp.MustCompile(`^\s*([^\s<>{}"]+)\s*`)
)
// glmCall reads GLM's own call (#906, nullburn: GLM-5.3-Flash through
// vLLM wrote them into its text in Codex): the tool's name right after
// <tool_call>, then <arg_key>…</arg_key><arg_value>…</arg_value> for
// each argument, then </tool_call>. A value that is JSON (a number, an
// object, a list, true) is taken as it, as GLM's template writes them;
// else it is the string it says. It says how much of s it took.
func glmCall(s string, names map[string]bool) (writtenCall, int, bool) {
m := glmName.FindStringSubmatchIndex(s)
if m == nil || !names[s[m[2]:m[3]]] {
return writtenCall{}, 0, false
}
name, n := s[m[2]:m[3]], m[1]
args := map[string]any{}
for {
p := glmPair.FindStringSubmatchIndex(s[n:])
if p == nil {
break
}
key, val := strings.TrimSpace(s[n+p[2]:n+p[3]]), s[n+p[4]:n+p[5]]
var v any
if json.Unmarshal([]byte(strings.TrimSpace(val)), &v) == nil {
args[key] = v
} else {
args[key] = val
}
n += p[1]
}
t := strings.TrimLeft(s[n:], " \t\r\n")
switch {
case strings.HasPrefix(t, "</tool_call>"):
n = len(s) - len(t) + len("</tool_call>")
case t != "" && !strings.HasPrefix(t, "<tool_call>"):
// something else follows the name: not a call of GLM's
return writtenCall{}, 0, false
}
b, err := json.Marshal(args)
if err != nil {
return writtenCall{}, 0, false
}
return writtenCall{Name: name, Args: b}, n, true
}
// dsmlCall reads DeepSeek's <|DSML|invoke name="…"> and its parameters,
// up to its close, the next invoke or the end of s.
func dsmlCall(s, name string, open int, names map[string]bool) (writtenCall, int, bool) {
+183
View File
@@ -0,0 +1,183 @@
package gateway
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/yetone/magpie/internal/provider"
)
// glmExec is GLM's own call of Codex's exec, as GLM-5.3-Flash through vLLM
// wrote it into its text (#906, nullburn).
const glmExec = "<tool_call>exec\n<arg_key>input</arg_key>\n<arg_value>const r = await tools.shell({cmd: \"ls\"});\nconsole.log(r);</arg_value>\n</tool_call>"
const glmExecInput = "const r = await tools.shell({cmd: \"ls\"});\nconsole.log(r);"
// responsesItems sends body to the gateway's Responses endpoint, upstream
// answering up, and gives back the output items the client read.
func responsesItems(t *testing.T, body string, up ...string) (items []map[string]any, raw string) {
t.Helper()
fresh(t)
a := &scripted{replies: []reply{{200, "text/event-stream", sse(append(up, `data: [DONE]`)...)}}}
scriptedOn(t, "a", provider.Chat, a)
rec := httptest.NewRecorder()
New().Handler().ServeHTTP(rec, httptest.NewRequest("POST", "/v1/responses", strings.NewReader(body)))
if rec.Code != 200 {
t.Fatalf("%d %s", rec.Code, rec.Body.String())
}
raw = rec.Body.String()
if strings.Contains(body, `"stream":true`) {
for _, line := range strings.Split(raw, "\n") {
data, ok := strings.CutPrefix(line, "data: ")
if !ok {
continue
}
var ev struct {
Type string `json:"type"`
Item map[string]any `json:"item"`
}
if json.Unmarshal([]byte(data), &ev) == nil && ev.Type == "response.output_item.done" {
items = append(items, ev.Item)
}
}
return items, raw
}
var r struct {
Output []map[string]any `json:"output"`
}
if err := json.Unmarshal([]byte(raw), &r); err != nil {
t.Fatalf("unreadable reply: %v\n%s", err, raw)
}
return r.Output, raw
}
// Codex's exec is a custom tool: GLM's call of it, its tags split across
// chunks, reaches Codex as the custom_tool_call it is, with the code as
// its input, after the text before it — streamed and whole.
func TestGLMTextCallCustomTool(t *testing.T) {
text := "Let me look.\n\n" + glmExec
for _, stream := range []string{"true", "false"} {
items, raw := responsesItems(t, `{"model":"a/m","stream":`+stream+`,"input":"list the files",`+
`"tools":[{"type":"custom","name":"exec","description":"Run JavaScript.","format":{"type":"grammar","syntax":"lark","definition":"start: /[\\s\\S]+/"}}]}`,
chunkOf(text[:20]), chunkOf(text[20:31]), chunkOf(text[31:52]), chunkOf(text[52:]), stopChunk)
var call map[string]any
var said string
for _, it := range items {
switch it["type"] {
case "custom_tool_call":
call = it
case "message":
b, _ := json.Marshal(it["content"])
said += string(b)
}
}
if call == nil || call["name"] != "exec" || call["input"] != glmExecInput {
t.Fatalf("stream=%s call %v\n%s", stream, call, raw)
}
if !strings.Contains(said, "Let me look.") || strings.Contains(raw, "arg_key") || strings.Contains(raw, "tool_call>") {
t.Fatalf("stream=%s text %s\n%s", stream, said, raw)
}
}
}
// A function tool's arguments: a value that is JSON is kept as JSON, any
// other is the string it says; two calls in a row are both made.
func TestGLMTextCallFunctionTool(t *testing.T) {
items, raw := responsesItems(t, `{"model":"a/m","stream":true,"input":"go",`+
`"tools":[{"type":"function","name":"shell","parameters":{"type":"object"}},{"type":"function","name":"read","parameters":{"type":"object"}}]}`,
chunkOf("<tool_call>shell<arg_key>command</arg_key><arg_value>[\"ls\",\"-la\"]</arg_value><arg_key>timeout_ms</arg_key><arg_value>"),
chunkOf("5000</arg_value><arg_key>workdir</arg_key><arg_value>/tmp/x</arg_value></tool_call>\n<tool_call>read\n<arg_key>path</arg_key>\n<arg_value>a.md</arg_value>\n</tool_call>"),
stopChunk)
var calls []map[string]any
for _, it := range items {
if it["type"] == "function_call" {
calls = append(calls, it)
}
}
if len(calls) != 2 || calls[0]["name"] != "shell" || calls[1]["name"] != "read" || strings.Contains(raw, "arg_value") {
t.Fatalf("calls %v\n%s", calls, raw)
}
var shell map[string]any
if err := json.Unmarshal([]byte(calls[0]["arguments"].(string)), &shell); err != nil {
t.Fatalf("arguments %v: %v", calls[0]["arguments"], err)
}
cmd, _ := shell["command"].([]any)
if len(cmd) != 2 || cmd[0] != "ls" || shell["timeout_ms"] != float64(5000) || shell["workdir"] != "/tmp/x" {
t.Fatalf("shell %v", shell)
}
if calls[1]["arguments"] != `{"path":"a.md"}` {
t.Fatalf("read %v", calls[1]["arguments"])
}
}
// Chat's relay and Anthropic's translation read GLM's call as well.
func TestGLMTextCallChatAndMessages(t *testing.T) {
text, calls, finish, raw := relayedChat(t, `{"model":"a/m","stream":true,"messages":[{"role":"user","content":"hi"}],`+textCallTools+`}`,
chunkOf("Looking.<tool_call>bash<arg_key>com"), chunkOf("mand</arg_key><arg_value>ls -la</arg_value></tool_"), chunkOf("call>"), stopChunk)
if text != "Looking." || calls["bash"] != `{"command":"ls -la"}` || finish != "tool_calls" || strings.Contains(raw, "arg_key") {
t.Fatalf("chat: text %q calls %v finish %q\n%s", text, calls, finish, raw)
}
fresh(t)
up := sse(chunkOf("<tool_call>bash\n<arg_key>command</arg_key>\n<arg_value>ls</arg_value>\n</tool_call>"), stopChunk, `data: [DONE]`)
a := &scripted{replies: []reply{{200, "text/event-stream", up}}}
scriptedOn(t, "a", provider.Chat, a)
for _, stream := range []string{"true", "false"} {
a.replies = []reply{{200, "text/event-stream", up}}
rec := httptest.NewRecorder()
New().Handler().ServeHTTP(rec, httptest.NewRequest("POST", "/v1/messages", strings.NewReader(
`{"model":"a/m","max_tokens":100,"stream":`+stream+`,"messages":[{"role":"user","content":"hi"}],"tools":[{"name":"bash","input_schema":{"type":"object"}}]}`)))
body := rec.Body.String()
if rec.Code != 200 || strings.Contains(body, "arg_key") || !strings.Contains(body, `"tool_use"`) || !strings.Contains(body, `"stop_reason":"tool_use"`) {
t.Fatalf("messages stream=%s %d %s", stream, rec.Code, body)
}
}
}
// A GLM block naming a tool the request didn't offer stays the text it was.
func TestGLMTextCallUnofferedLeftAsText(t *testing.T) {
block := "<tool_call>rm_rf<arg_key>path</arg_key><arg_value>/</arg_value></tool_call>"
text, calls, finish, _ := relayedChat(t, `{"model":"a/m","stream":true,"messages":[{"role":"user","content":"hi"}],`+textCallTools+`}`,
chunkOf("Like this: "+block), stopChunk)
if text != "Like this: "+block || len(calls) != 0 || finish != "stop" {
t.Fatalf("text %q calls %v finish %q", text, calls, finish)
}
items, raw := responsesItems(t, `{"model":"a/m","stream":true,"input":"go","tools":[{"type":"custom","name":"exec"}]}`,
chunkOf(block), stopChunk)
for _, it := range items {
if it["type"] != "message" {
t.Fatalf("item %v\n%s", it, raw)
}
}
if !strings.Contains(raw, "rm_rf") {
t.Fatalf("text lost: %s", raw)
}
}
// A plugin's model writing GLM's call is read the same: Codex gets its
// custom tool call.
func TestPluginGLMTextCall(t *testing.T) {
pid := besideFake(t, "glm-local", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
for _, part := range []string{glmExec[:15], glmExec[15:]} {
b, _ := json.Marshal(map[string]any{"id": "c1", "object": "chat.completion.chunk", "choices": []any{map[string]any{"index": 0, "delta": map[string]any{"content": part}, "finish_reason": nil}}})
io.WriteString(w, "data: "+string(b)+"\n\n")
}
io.WriteString(w, `data: {"id":"c1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`+"\n\ndata: [DONE]\n\n")
}))
req := httptest.NewRequest("POST", "/v1/responses", strings.NewReader(`{"model":"`+pid+`/fake-1","stream":true,"input":"go","tools":[{"type":"custom","name":"exec"}]}`))
req.Header.Set("Authorization", "Bearer magpie")
rec := httptest.NewRecorder()
New().Handler().ServeHTTP(rec, req)
body := rec.Body.String()
if rec.Code != 200 || strings.Contains(body, "arg_key") || !strings.Contains(body, `"custom_tool_call"`) {
t.Fatalf("%d %s", rec.Code, body)
}
in, _ := json.Marshal(glmExecInput)
if !strings.Contains(body, `"input":`+string(in)) {
t.Fatalf("input: %s", body)
}
}