Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions parse_defensive_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package cogito

import "testing"

func TestParseStreamedToolArgs_Defensive(t *testing.T) {
cases := []struct {
name, raw, key string
val any
wantErr bool
}{
{"plain valid", `{"query":"x"}`, "query", "x", false},
{"concatenated duplicate", `{"query":"x"}{"query":"x"}`, "query", "x", false},
{"concatenated different -> first", `{"city":"NYC"}{"city":"LA"}`, "city", "NYC", false},
{"malformed", `{"query":`, "", nil, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
out := make(map[string]any)
err := parseStreamedToolArgs(c.raw, &out)
if c.wantErr {
if err == nil {
t.Fatalf("want err")
}
return
}
if err != nil {
t.Fatalf("err: %v", err)
}
if out[c.key] != c.val {
t.Fatalf("got %v want %v", out[c.key], c.val)
}
})
}
}
23 changes: 21 additions & 2 deletions tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,25 @@ import (
"github.com/sashabaranov/go-openai/jsonschema"
)

// parseStreamedToolArgs unmarshals streamed tool-call arguments into out, tolerant of a known
// defect where the full arguments object is concatenated more than once ("{...}{...}") — e.g.
// some providers behind a proxy that maps Anthropic/Gemini tool-call streaming into OpenAI
// deltas re-emit the complete object. A plain json.Unmarshal then fails with
// "invalid character '{' after top-level value". On that failure we recover the FIRST complete
// top-level JSON object via json.Decoder; correct incremental-delta streams take the fast path.
func parseStreamedToolArgs(raw string, out *map[string]any) error {
if err := json.Unmarshal([]byte(raw), out); err == nil {
return nil
}
dec := json.NewDecoder(strings.NewReader(raw))
recovered := make(map[string]any)
if err := dec.Decode(&recovered); err != nil {
return err
}
*out = recovered
return nil
}

var (
ErrNoToolSelected error = errors.New("no tool selected by the LLM")
ErrLoopDetected error = errors.New("loop detected: same tool called repeatedly with same parameters")
Expand Down Expand Up @@ -402,7 +421,7 @@ func decisionWithStreaming(ctx context.Context, llm LLM, conversation []openai.C
allParsed := true
for _, toolCall := range toolCalls {
arguments := make(map[string]any)
if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &arguments); err != nil {
if err := parseStreamedToolArgs(toolCall.Function.Arguments, &arguments); err != nil {
lastErr = err
xlog.Warn("Attempt to parse streamed tool arguments failed", "attempt", attempts+1, "error", err)
allParsed = false
Expand Down Expand Up @@ -505,7 +524,7 @@ func decision(ctx context.Context, llm LLM, conversation []openai.ChatCompletion
for _, toolCall := range msg.ToolCalls {
arguments := make(map[string]any)

if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &arguments); err != nil {
if err := parseStreamedToolArgs(toolCall.Function.Arguments, &arguments); err != nil {
lastErr = err
xlog.Warn("Attempt to parse tool arguments failed", "attempt", attempts+1, "error", err)
if werr := backoffOrCancel(ctx, attempts); werr != nil {
Expand Down