fix: accept prose-prefixed tool JSON and heal invalid escape sequences
This commit is contained in:
1 parent
4cc4504a18
commit
a9821d120e
3 files changed
+97
-4
No files matched your search
@@ -64,7 +64,13 @@ func ToolCallTruncated(text string) bool {
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(cand, `{"tool_calls"`) {
|
||||
return false
|
||||
// The model sometimes opens with a sentence before the tool JSON:
|
||||
// evaluate the payload itself, mirroring TryParseToolCalls.
|
||||
i := strings.Index(cand, `{"tool_calls"`)
|
||||
if i <= 0 {
|
||||
return false
|
||||
}
|
||||
cand = cand[i:]
|
||||
}
|
||||
return !json.Valid([]byte(cand))
|
||||
}
|
||||
@@ -118,8 +124,22 @@ func (st *escapeState) feed(s string, out *strings.Builder) {
|
||||
}
|
||||
switch c {
|
||||
case '\\':
|
||||
st.escaped = true
|
||||
out.WriteByte(c)
|
||||
if i+1 >= len(s) {
|
||||
// dangling escape: carried into the next feed so a
|
||||
// continuation piece can complete it
|
||||
st.escaped = true
|
||||
out.WriteByte('\\')
|
||||
continue
|
||||
}
|
||||
if j := escapeEndAt(s, i); j > i {
|
||||
out.WriteString(s[i : j+1])
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
// invalid escape (…\d): double the backslash so the text
|
||||
// parses — matches escapeStringBody used by forceCloseJSON,
|
||||
// keeping streamed and repaired arguments identical
|
||||
out.WriteString(`\\`)
|
||||
case '"':
|
||||
st.inString = false
|
||||
out.WriteByte(c)
|
||||
@@ -140,10 +160,36 @@ func (st *escapeState) feed(s string, out *strings.Builder) {
|
||||
}
|
||||
|
||||
func needsEscape(s string) bool {
|
||||
inString, escaped := false, false
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] < 0x20 {
|
||||
c := s[i]
|
||||
if c < 0x20 {
|
||||
return true
|
||||
}
|
||||
if !inString {
|
||||
if c == '"' {
|
||||
inString = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '\\':
|
||||
if i+1 >= len(s) {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
j := escapeEndAt(s, i)
|
||||
if j == i {
|
||||
return true // invalid escape — feed doubles it
|
||||
}
|
||||
i = j
|
||||
case '"':
|
||||
inString = false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ func TestToolCallTruncated(t *testing.T) {
|
||||
{fenced, true},
|
||||
{"", false},
|
||||
{`{"other": true}`, false},
|
||||
// prose before the payload: mirrored from TryParseToolCalls
|
||||
{"我先说明一下:\n\n" + valid, false},
|
||||
{"我先说明一下:\n\n" + truncated, true},
|
||||
{"我先说明一下:\n\n" + fenced, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ToolCallTruncated(c.in); got != c.want {
|
||||
@@ -64,6 +68,41 @@ func TestEscapeControlChars(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapeControlCharsDoublesInvalidEscapes(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
// \q is not a JSON escape — the backslash must be doubled
|
||||
{`{"a": "x\qy"}`, `{"a": "x\\qy"}`},
|
||||
// valid pairs survive, invalid ones heal
|
||||
{`{"a": "ok\nstill\q"}`, `{"a": "ok\nstill\\q"}`},
|
||||
// dangling escape at the end is carried, not doubled
|
||||
{`{"a": "dangling\`, `{"a": "dangling\`},
|
||||
// no backslash at all: untouched
|
||||
{`{"a": "plain"}`, `{"a": "plain"}`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := EscapeControlChars(c.in); got != c.want {
|
||||
t.Errorf("EscapeControlChars(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
if !json.Valid([]byte(EscapeControlChars(`{"a": "x\qy"}`))) {
|
||||
t.Fatal("healed text is not valid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInnerEscapeMatchesParseHeal pins the contract that keeps streamed tool
|
||||
// arguments identical to the final repaired parse.
|
||||
func TestInnerEscapeMatchesParseHeal(t *testing.T) {
|
||||
inner := `{"content": "a\qb` + "\n" + `c\qd"}`
|
||||
streamed := innerEscape(inner)
|
||||
healed := EscapeControlChars(inner)
|
||||
if streamed != healed {
|
||||
t.Fatalf("stream/parse heal mismatch:\n stream=%q\n parse =%q", streamed, healed)
|
||||
}
|
||||
if !json.Valid([]byte(healed)) {
|
||||
t.Fatalf("healed inner invalid: %q", healed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimOverlap(t *testing.T) {
|
||||
cases := []struct{ acc, head, want string }{
|
||||
{"...fillStyle='#F4", "F4E2B8';draw()", "E2B8';draw()"},
|
||||
|
||||
@@ -17,6 +17,9 @@ func TryParseToolCalls(text string) (string, []ToolCall, bool) {
|
||||
var candidates []string
|
||||
if strings.HasPrefix(trimmed, "{") {
|
||||
candidates = append(candidates, trimmed)
|
||||
} else if i := strings.Index(trimmed, `{"tool_calls"`); i > 0 {
|
||||
// prose before the tool JSON: start the candidate at the payload
|
||||
candidates = append(candidates, strings.TrimSpace(trimmed[i:]))
|
||||
}
|
||||
if strings.Contains(trimmed, "```json") {
|
||||
for _, part := range strings.Split(trimmed, "```json")[1:] {
|
||||
@@ -45,6 +48,11 @@ func TryParseToolCalls(text string) (string, []ToolCall, bool) {
|
||||
if !decodeToolPayload(cand, &payload) {
|
||||
continue
|
||||
}
|
||||
// A prose-prefix candidate runs to the end of the text; keep only
|
||||
// the payload itself so prose after the JSON survives in remaining.
|
||||
if full, ok := jsonPrefix(cand); ok && len(full) < len(cand) {
|
||||
cand = full
|
||||
}
|
||||
if payload.ToolCalls == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user