From 307f4136871d2e46fbf86a73ef655c9ca591ff6e Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 5 Oct 2026 21:34:33 +0800 Subject: [PATCH] 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) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: execinput…. magpie held text from 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 is not JSON: the tool's name, then each / 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). --- internal/gateway/textcalls.go | 55 +++++++- internal/gateway/textcalls_glm_test.go | 183 +++++++++++++++++++++++++ 2 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 internal/gateway/textcalls_glm_test.go diff --git a/internal/gateway/textcalls.go b/internal/gateway/textcalls.go index 38863d74..52c0fe3e 100644 --- a/internal/gateway/textcalls.go +++ b/internal/gateway/textcalls.go @@ -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' {"name": …, "arguments": …} -// , or DeepSeek's own <|DSML|invoke name="…"> with its +// , GLM's name…… +// (#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(""):], names) + if !good && !strings.HasPrefix(strings.TrimLeft(s[h+len(""):], " \t\r\n"), "{") { + // GLM's own: the name, then its arguments in pairs (#906) + c, n, good = glmCall(s[h+len(""):], 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*(.*?)\s*(.*?)`) + 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 +// , then …… for +// each argument, then . 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, ""): + n = len(s) - len(t) + len("") + case t != "" && !strings.HasPrefix(t, ""): + // 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) { diff --git a/internal/gateway/textcalls_glm_test.go b/internal/gateway/textcalls_glm_test.go new file mode 100644 index 00000000..439428c6 --- /dev/null +++ b/internal/gateway/textcalls_glm_test.go @@ -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 = "exec\ninput\nconst r = await tools.shell({cmd: \"ls\"});\nconsole.log(r);\n" + +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("shellcommand[\"ls\",\"-la\"]timeout_ms"), + chunkOf("5000workdir/tmp/x\nread\npath\na.md\n"), + 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.bashcom"), chunkOf("mandls -la"), 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("bash\ncommand\nls\n"), 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 := "rm_rfpath/" + 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) + } +}