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)
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.
This commit is contained in:
1 parent
169c23c18e
commit
d358107766
2 files changed
+191
-2
No files matched your search
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user