From d3581077665f3fe9fd0f8a565ed29693681b074a Mon Sep 17 00:00:00 2001 From: yetone Date: Thu, 8 Oct 2026 19:57:21 +0800 Subject: [PATCH] gateway: Devin's call ids reach the agent as [A-Za-z0-9_-] only and go back to Devin as Devin gave them, so Claude Code runs SWE-2's tool calls (#1304, StarMoonCity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SWE-2 names a call after its tool and turn ("Bash:0#a65b6a5e02194b87bbc796c4428c946d"). Claude Code takes a tool_use id only of [A-Za-z0-9_-], dropped every such call and failed the turn with "The model's tool call could not be parsed (retry also failed)"; Codex and pi take any id. decodeDevin now sends an id with another character, or one beginning "dv_", as "dv_" and its base64url (devinOutID), and buildDevin turns it back (devinInID) on the assistant's call and on the result that answers it, so Devin sees its own id. A safe id goes both ways as it came. It is stateless, and the same mapping the Devin plugin makes in 0.1.13 (outID/inID). TestDevinCallIDsSafeBothWays takes Devin's reply with the reporter's literal id through serveDevin on Anthropic, Chat and Responses, and answers the call in the next request of each: with devinOutID and devinInID made to return the id as it came (the old behavior) it fails "anthropic: call id \"Bash:0#a65b6a5e02194b87bbc796c4428c946d\", want one of [A-Za-z0-9_-] only"; with only devinInID taken out (a one-way clean, as the issue proposed) it fails too, Devin getting the cleaned id. TestDevinSafeCallIDsUnchanged keeps safe ids, and a caller's own "dv_…", as they came. go test -tags nogui ./internal/provider ./internal/gateway pass on Linux (golang:1.26.8 in Docker, temp HOME, module cache mounted read-only): on this Mac every freshly built test binary hangs at exec (syspolicyd at 100% CPU), so no darwin test run. go vet -tags nogui for darwin, GOOS=linux and GOOS=windows and gofmt -l pass. Not tried against a real Devin account. --- internal/gateway/devin.go | 35 +++++- internal/gateway/devin_tool_ids_test.go | 158 ++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 internal/gateway/devin_tool_ids_test.go diff --git a/internal/gateway/devin.go b/internal/gateway/devin.go index 668fb1e9..ef74faa9 100644 --- a/internal/gateway/devin.go +++ b/internal/gateway/devin.go @@ -20,15 +20,18 @@ import ( "bytes" "compress/gzip" "context" + "encoding/base64" "encoding/binary" "encoding/json" "errors" "io" "math" "net/http" + "regexp" "runtime" "slices" "strings" + "unicode/utf8" "github.com/yetone/magpie/internal/provider" ) @@ -309,6 +312,33 @@ func inlineImages(ims []Part) []Part { return out } +// Devin names a call after its tool and turn (Bash:0#a65b6a5e…), and +// Claude Code takes a tool_use id only of [A-Za-z0-9_-]: it drops a call +// whose id has the ':' or '#', and the turn fails "could not be parsed" +// (#1304). Such an id goes out as "dv_" and its base64url, and comes back +// to Devin as it was, so its result still answers its call. An id that is +// already safe goes out as it came, unless it begins "dv_". The Devin +// plugin (packages/devin, outID and inID) maps the same way. +var devinSafeID = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +func devinOutID(id string) string { + if devinSafeID.MatchString(id) && !strings.HasPrefix(id, "dv_") { + return id + } + return "dv_" + base64.RawURLEncoding.EncodeToString([]byte(id)) +} + +func devinInID(id string) string { + if !strings.HasPrefix(id, "dv_") || !devinSafeID.MatchString(id) { + return id + } + raw, err := base64.RawURLEncoding.DecodeString(id[3:]) + if err != nil || len(raw) == 0 || !utf8.Valid(raw) || devinOutID(string(raw)) != id { + return id + } + return string(raw) +} + // buildDevin is the GetChatMessage request for r, to the model uid. func buildDevin(r *Request, uid, key string) []byte { var msgs []devinMsg @@ -330,6 +360,7 @@ func buildDevin(r *Request, uid, key string) []byte { texts = append(texts, p.Text) } case ToolCall: + p.ID = devinInID(p.ID) a.calls = append(a.calls, p) case Thinking: if strings.HasPrefix(p.Signature, "sealed.") { @@ -363,7 +394,7 @@ func buildDevin(r *Request, uid, key string) []byte { case ToolResult: // only a call just made is answered, and only once for i, c := range pending { - if c.ID != p.CallID { + if c.ID != devinInID(p.CallID) { continue } out := p.Text @@ -617,7 +648,7 @@ func (d *devinDecoder) frame(b []byte) []Event { if id == "" { id = "call_" + randomToken()[:24] } - evs = append(evs, Event{Kind: KToolStart, ID: id, Name: name}) + evs = append(evs, Event{Kind: KToolStart, ID: devinOutID(id), Name: name}) } if args != "" { evs = append(evs, Event{Kind: KToolArgs, Text: args}) diff --git a/internal/gateway/devin_tool_ids_test.go b/internal/gateway/devin_tool_ids_test.go new file mode 100644 index 00000000..a8e7566c --- /dev/null +++ b/internal/gateway/devin_tool_ids_test.go @@ -0,0 +1,158 @@ +package gateway + +import ( + "bytes" + "encoding/json" + "regexp" + "strings" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// devinRawID is the call id SWE-2 gave in #1304 (StarMoonCity's capture). +// Claude Code takes a tool_use id only of [A-Za-z0-9_-] and dropped the +// call: "The model's tool call could not be parsed (retry also failed)". +const devinRawID = "Bash:0#a65b6a5e02194b87bbc796c4428c946d" + +var anthropicSafeID = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +// devinCallReply is Devin's reply making that call. +func devinCallReply() []byte { + return bytes.Join([][]byte{ + devinFrame(0, pb{}.bytes(6, pb{}.str(1, devinRawID).str(2, "Bash").str(3, `{"command":"date"}`)), false), + devinFrame(0, pb{}.varint(5, 10), false), + devinFrame(2, []byte("{}"), false), + }, nil) +} + +// devinIDsSent is the ids of the calls and of the results the request for r +// gives Devin. +func devinIDsSent(t *testing.T, r *Request) (calls, answers []string) { + t.Helper() + for _, m := range decodeDevinRequest(t, buildDevin(r, "swe-2-high", "k")).msgs { + for _, f := range m { + switch f.num { + case 6: + calls = append(calls, string(pbFields(f.data)[0].data)) + case 7: + answers = append(answers, string(f.data)) + } + } + } + return +} + +// On each protocol the call's id reaches the agent of [A-Za-z0-9_-] only, +// and the agent's next request, answering it under that id, gives Devin +// the call and its result under the id Devin gave. +func TestDevinCallIDsSafeBothWays(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + done := devinUpstream(t, devinCallReply()) + defer done() + + for _, c := range []struct { + from provider.Protocol + ask string + id func(body []byte) string + next func(id string) string + parse func([]byte) (*Request, error) + }{ + {provider.Anthropic, `{"model":"x","max_tokens":10,"messages":[{"role":"user","content":"用 Bash 跑一下 date"}]}`, + func(b []byte) string { + var m struct{ Content []struct{ Type, ID string } } + json.Unmarshal(b, &m) + for _, p := range m.Content { + if p.Type == "tool_use" { + return p.ID + } + } + return "" + }, + func(id string) string { + return `{"model":"x","max_tokens":10,"messages":[{"role":"user","content":"用 Bash 跑一下 date"}, + {"role":"assistant","content":[{"type":"tool_use","id":"` + id + `","name":"Bash","input":{"command":"date"}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"` + id + `","content":"Thu Oct 8 10:00:00 CST 2026"}]}]}` + }, parseAnthropic}, + {provider.Chat, `{"model":"x","messages":[{"role":"user","content":"用 Bash 跑一下 date"}]}`, + func(b []byte) string { + var m struct { + Choices []struct { + Message struct { + ToolCalls []struct{ ID string } `json:"tool_calls"` + } + } + } + json.Unmarshal(b, &m) + if len(m.Choices) == 0 || len(m.Choices[0].Message.ToolCalls) == 0 { + return "" + } + return m.Choices[0].Message.ToolCalls[0].ID + }, + func(id string) string { + return `{"model":"x","messages":[{"role":"user","content":"用 Bash 跑一下 date"}, + {"role":"assistant","content":null,"tool_calls":[{"id":"` + id + `","type":"function","function":{"name":"Bash","arguments":"{\"command\":\"date\"}"}}]}, + {"role":"tool","tool_call_id":"` + id + `","content":"Thu Oct 8 10:00:00 CST 2026"}]}` + }, parseChat}, + {provider.Responses, `{"model":"x","input":"用 Bash 跑一下 date"}`, + func(b []byte) string { + var m struct { + Output []struct { + Type string + CallID string `json:"call_id"` + } + } + json.Unmarshal(b, &m) + for _, o := range m.Output { + if o.Type == "function_call" { + return o.CallID + } + } + return "" + }, + func(id string) string { + return `{"model":"x","input":[{"role":"user","content":"用 Bash 跑一下 date"}, + {"type":"function_call","call_id":"` + id + `","name":"Bash","arguments":"{\"command\":\"date\"}"}, + {"type":"function_call_output","call_id":"` + id + `","output":"Thu Oct 8 10:00:00 CST 2026"}]}` + }, parseResponses}, + } { + w := serveDevinOnce(t, c.from, c.ask) + id := c.id(w.Body.Bytes()) + if w.Code != 200 || !anthropicSafeID.MatchString(id) { + t.Fatalf("%s: call id %q, want one of [A-Za-z0-9_-] only (%d %s)", c.from, id, w.Code, w.Body) + } + r, err := c.parse([]byte(c.next(id))) + if err != nil { + t.Fatalf("%s: %v", c.from, err) + } + calls, answers := devinIDsSent(t, r) + if strings.Join(calls, ",") != devinRawID || strings.Join(answers, ",") != devinRawID { + t.Fatalf("%s: Devin got calls %q, results for %q, want both %q", c.from, calls, answers, devinRawID) + } + } + + // streamed to Claude Code too + w := serveDevinOnce(t, provider.Anthropic, `{"model":"x","max_tokens":10,"stream":true,"messages":[{"role":"user","content":"hi"}]}`) + if out := w.Body.String(); strings.Contains(out, devinRawID) || !strings.Contains(out, `"id":"`+devinOutID(devinRawID)+`"`) { + t.Fatalf("streamed: %s", out) + } +} + +// An id that is already safe goes both ways as it came; one of the +// caller's own that begins dv_ (no id devinOutID makes) does too. +func TestDevinSafeCallIDsUnchanged(t *testing.T) { + for _, id := range []string{"toolu_01AbC", "call_abc-123", "t1", "dv_", "dv_abc"} { + if got := devinInID(devinOutID(id)); got != id { + t.Errorf("%q round-trips to %q", id, got) + } + if got := devinInID(id); got != id { + t.Errorf("%q from the agent reaches Devin as %q", id, got) + } + } + for _, id := range []string{"toolu_01AbC", "call_abc-123"} { + if got := devinOutID(id); got != id { + t.Errorf("%q goes out as %q", id, got) + } + } +}