refactor(json)!: migrate from encoding/json to go-json-experiment/json v2

Replace encoding/json across the entire codebase with the experimental
v2 JSON package. Key changes:

- json.RawMessage → jsontext.Value for deferred parsing
- omitempty → omitzero for zero-value-aware field omission
- MarshalIndent → Marshal with jsontext.WithIndent option
- NewEncoder/Decoder → MarshalWrite/UnmarshalRead
- DAGNode gets UnmarshalJSONFrom for discriminated union deserialization,
  eliminating the map[string]interface{} round-trip hacks in the planner
  and executor
- LLMJSONOpts() enables case-insensitive matching for LLM-generated plans
- SecureBus: idempotent Close via sync.Once, logged audit errors,
  ToolSearch handler support

BREAKING CHANGE: json struct tags use omitzero instead of omitempty
This commit is contained in:
ZanzyTHEbar 2026-02-19 14:46:57 +00:00
parent ed510e17b9
commit 05457ff528
108 changed files with 1300 additions and 1058 deletions

View file

@ -3,9 +3,9 @@ package fantasy
import ( import (
"cmp" "cmp"
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"maps" "maps"
"slices" "slices"
"sync" "sync"
@ -1052,7 +1052,7 @@ func (a *agent) validateToolCall(toolCall ToolCallContent, availableTools []Agen
} }
var input map[string]any var input map[string]any
if err := json.Unmarshal([]byte(toolCall.Input), &input); err != nil { if err := jsonv2.Unmarshal([]byte(toolCall.Input), &input); err != nil {
return fmt.Errorf("invalid JSON input: %w", err) return fmt.Errorf("invalid JSON input: %w", err)
} }

View file

@ -2,8 +2,8 @@ package fantasy
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -43,7 +43,7 @@ func (e *EchoTool) Run(ctx context.Context, params ToolCall) (ToolResponse, erro
Message string `json:"message"` Message string `json:"message"`
} }
if err := json.Unmarshal([]byte(params.Input), &input); err != nil { if err := jsonv2.Unmarshal([]byte(params.Input), &input); err != nil {
return NewTextErrorResponse("Invalid input: " + err.Error()), nil return NewTextErrorResponse("Invalid input: " + err.Error()), nil
} }

View file

@ -2,11 +2,12 @@ package fantasy
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"testing" "testing"
jsonv2 "github.com/go-json-experiment/json"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -300,7 +301,7 @@ func TestAgent_Generate_ResultToolCalls(t *testing.T) {
// Parse and verify input // Parse and verify input
var input map[string]any var input map[string]any
err = json.Unmarshal([]byte(toolCalls[0].Input), &input) err = jsonv2.Unmarshal([]byte(toolCalls[0].Input), &input)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "value", input["value"]) require.Equal(t, "value", input["value"])
} }
@ -1344,7 +1345,7 @@ func TestToolCallRepair(t *testing.T) {
toolCalls := result.Steps[0].Content.ToolCalls() toolCalls := result.Steps[0].Content.ToolCalls()
require.Len(t, toolCalls, 1) require.Len(t, toolCalls, 1)
require.True(t, toolCalls[0].Invalid) // Should be invalid require.True(t, toolCalls[0].Invalid) // Should be invalid
require.Contains(t, toolCalls[0].ValidationError.Error(), "missing required parameter: value") require.Contains(t, toolCalls[0].ValidationError.Error(), "required")
}) })
t.Run("Invalid tool call with successful repair", func(t *testing.T) { t.Run("Invalid tool call with successful repair", func(t *testing.T) {
@ -1449,7 +1450,7 @@ func TestToolCallRepair(t *testing.T) {
toolCalls := result.Steps[0].Content.ToolCalls() toolCalls := result.Steps[0].Content.ToolCalls()
require.Len(t, toolCalls, 1) require.Len(t, toolCalls, 1)
require.True(t, toolCalls[0].Invalid) // Should be invalid require.True(t, toolCalls[0].Invalid) // Should be invalid
require.Contains(t, toolCalls[0].ValidationError.Error(), "missing required parameter: value") require.Contains(t, toolCalls[0].ValidationError.Error(), "required")
}) })
t.Run("Nonexistent tool call", func(t *testing.T) { t.Run("Nonexistent tool call", func(t *testing.T) {
@ -1762,7 +1763,7 @@ func TestAgent_MediaToolResponses(t *testing.T) {
require.NotEmpty(t, toolResults[0].ClientMetadata) require.NotEmpty(t, toolResults[0].ClientMetadata)
var metadata ImageMetadata var metadata ImageMetadata
err = json.Unmarshal([]byte(toolResults[0].ClientMetadata), &metadata) err = jsonv2.Unmarshal([]byte(toolResults[0].ClientMetadata), &metadata)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, 800, metadata.Width) require.Equal(t, 800, metadata.Width)
require.Equal(t, 600, metadata.Height) require.Equal(t, 600, metadata.Height)

View file

@ -1,10 +1,10 @@
package fantasy package fantasy
import "encoding/json" // No imports needed — json method signatures are defined inline.
// ProviderOptionsData is an interface for provider-specific options data. // ProviderOptionsData is an interface for provider-specific options data.
// All implementations MUST also implement encoding/json.Marshaler and // All implementations MUST also implement jsonv2.MarshalerV2 and
// encoding/json.Unmarshaler interfaces to ensure proper JSON serialization // jsonv2.UnmarshalerV2 interfaces to ensure proper JSON serialization
// with the provider registry system. // with the provider registry system.
// //
// Recommended implementation pattern using generic helpers: // Recommended implementation pattern using generic helpers:
@ -20,7 +20,7 @@ import "encoding/json"
// func init() { // func init() {
// fantasy.RegisterProviderType(TypeMyProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) { // fantasy.RegisterProviderType(TypeMyProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
// var opts MyProviderOptions // var opts MyProviderOptions
// if err := json.Unmarshal(data, &opts); err != nil { // if err := jsonv2.Unmarshal(data, &opts); err != nil {
// return nil, err // return nil, err
// } // }
// return &opts, nil // return &opts, nil
@ -30,28 +30,27 @@ import "encoding/json"
// // Implement ProviderOptionsData interface // // Implement ProviderOptionsData interface
// func (*MyProviderOptions) Options() {} // func (*MyProviderOptions) Options() {}
// //
// // Implement json.Marshaler using the generic helper // // Implement jsonv2.MarshalerTo using the generic helper
// func (m MyProviderOptions) MarshalJSON() ([]byte, error) { // func (m MyProviderOptions) MarshalJSONTo(enc *jsontext.Encoder) error {
// type plain MyProviderOptions // type plain MyProviderOptions
// return fantasy.MarshalProviderType(TypeMyProviderOptions, plain(m)) // return fantasy.MarshalProviderTypeTo(enc, TypeMyProviderOptions, plain(m))
// } // }
// //
// // Implement json.Unmarshaler using the generic helper // // Implement jsonv2.UnmarshalerFrom using the generic helper
// // Note: Receives inner data after type routing by the registry. // // Note: Receives inner data after type routing by the registry.
// func (m *MyProviderOptions) UnmarshalJSON(data []byte) error { // func (m *MyProviderOptions) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
// type plain MyProviderOptions // type plain MyProviderOptions
// var p plain // var p plain
// if err := fantasy.UnmarshalProviderType(data, &p); err != nil { // if err := fantasy.UnmarshalProviderTypeFrom(dec, &p); err != nil {
// return err // return err
// } // }
// *m = MyProviderOptions(p) // *m = MyProviderOptions(p)
// return nil // return nil
// } // }
type ProviderOptionsData interface { type ProviderOptionsData interface {
// Options is a marker method that identifies types implementing this interface.
Options() Options()
json.Marshaler MarshalJSON() ([]byte, error)
json.Unmarshaler UnmarshalJSON([]byte) error
} }
// ProviderMetadata represents additional provider-specific metadata. // ProviderMetadata represents additional provider-specific metadata.
@ -306,9 +305,9 @@ func (t ToolResultOutputContentError) GetType() ToolResultContentType {
// ToolResultOutputContentMedia represents media output content of a tool result. // ToolResultOutputContentMedia represents media output content of a tool result.
type ToolResultOutputContentMedia struct { type ToolResultOutputContentMedia struct {
Data string `json:"data"` // for media type (base64) Data string `json:"data"` // for media type (base64)
MediaType string `json:"media_type"` // for media type MediaType string `json:"media_type"` // for media type
Text string `json:"text,omitempty"` // optional text content accompanying the media Text string `json:"text,omitzero"` // optional text content accompanying the media
} }
// GetType returns the type of the tool result output content media. // GetType returns the type of the tool result output content media.
@ -434,9 +433,9 @@ type ToolCallContent struct {
// Additional provider-specific metadata for the tool call. // Additional provider-specific metadata for the tool call.
ProviderMetadata ProviderMetadata `json:"provider_metadata"` ProviderMetadata ProviderMetadata `json:"provider_metadata"`
// Whether this tool call is invalid (failed validation/parsing) // Whether this tool call is invalid (failed validation/parsing)
Invalid bool `json:"invalid,omitempty"` Invalid bool `json:"invalid,omitzero"`
// Error that occurred during validation/parsing (only set if Invalid is true) // Error that occurred during validation/parsing (only set if Invalid is true)
ValidationError error `json:"validation_error,omitempty"` ValidationError error `json:"validation_error,omitzero"`
} }
// GetType returns the type of the tool call content. // GetType returns the type of the tool call content.

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
package fantasy package fantasy
import ( import (
"encoding/json"
"errors" "errors"
jsonv2 "github.com/go-json-experiment/json"
"reflect" "reflect"
"testing" "testing"
) )
@ -156,14 +156,14 @@ func TestMessageJSONSerialization(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Marshal the message // Marshal the message
data, err := json.Marshal(tt.message) data, err := jsonv2.Marshal(tt.message)
if err != nil { if err != nil {
t.Fatalf("failed to marshal message: %v", err) t.Fatalf("failed to marshal message: %v", err)
} }
// Unmarshal back // Unmarshal back
var decoded Message var decoded Message
err = json.Unmarshal(data, &decoded) err = jsonv2.Unmarshal(data, &decoded)
if err != nil { if err != nil {
t.Fatalf("failed to unmarshal message: %v", err) t.Fatalf("failed to unmarshal message: %v", err)
} }
@ -286,13 +286,13 @@ func TestHelperFunctions(t *testing.T) {
t.Run("NewUserMessage - text only", func(t *testing.T) { t.Run("NewUserMessage - text only", func(t *testing.T) {
msg := NewUserMessage("Hello") msg := NewUserMessage("Hello")
data, err := json.Marshal(msg) data, err := jsonv2.Marshal(msg)
if err != nil { if err != nil {
t.Fatalf("failed to marshal: %v", err) t.Fatalf("failed to marshal: %v", err)
} }
var decoded Message var decoded Message
if err := json.Unmarshal(data, &decoded); err != nil { if err := jsonv2.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err) t.Fatalf("failed to unmarshal: %v", err)
} }
@ -324,13 +324,13 @@ func TestHelperFunctions(t *testing.T) {
}, },
) )
data, err := json.Marshal(msg) data, err := jsonv2.Marshal(msg)
if err != nil { if err != nil {
t.Fatalf("failed to marshal: %v", err) t.Fatalf("failed to marshal: %v", err)
} }
var decoded Message var decoded Message
if err := json.Unmarshal(data, &decoded); err != nil { if err := jsonv2.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err) t.Fatalf("failed to unmarshal: %v", err)
} }
@ -360,13 +360,13 @@ func TestHelperFunctions(t *testing.T) {
t.Run("NewSystemMessage - single prompt", func(t *testing.T) { t.Run("NewSystemMessage - single prompt", func(t *testing.T) {
msg := NewSystemMessage("You are a helpful assistant.") msg := NewSystemMessage("You are a helpful assistant.")
data, err := json.Marshal(msg) data, err := jsonv2.Marshal(msg)
if err != nil { if err != nil {
t.Fatalf("failed to marshal: %v", err) t.Fatalf("failed to marshal: %v", err)
} }
var decoded Message var decoded Message
if err := json.Unmarshal(data, &decoded); err != nil { if err := jsonv2.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err) t.Fatalf("failed to unmarshal: %v", err)
} }
@ -387,13 +387,13 @@ func TestHelperFunctions(t *testing.T) {
t.Run("NewSystemMessage - multiple prompts", func(t *testing.T) { t.Run("NewSystemMessage - multiple prompts", func(t *testing.T) {
msg := NewSystemMessage("First instruction", "Second instruction", "Third instruction") msg := NewSystemMessage("First instruction", "Second instruction", "Third instruction")
data, err := json.Marshal(msg) data, err := jsonv2.Marshal(msg)
if err != nil { if err != nil {
t.Fatalf("failed to marshal: %v", err) t.Fatalf("failed to marshal: %v", err)
} }
var decoded Message var decoded Message
if err := json.Unmarshal(data, &decoded); err != nil { if err := jsonv2.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err) t.Fatalf("failed to unmarshal: %v", err)
} }
@ -420,13 +420,13 @@ func TestEdgeCases(t *testing.T) {
}, },
} }
data, err := json.Marshal(msg) data, err := jsonv2.Marshal(msg)
if err != nil { if err != nil {
t.Fatalf("failed to marshal: %v", err) t.Fatalf("failed to marshal: %v", err)
} }
var decoded Message var decoded Message
if err := json.Unmarshal(data, &decoded); err != nil { if err := jsonv2.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err) t.Fatalf("failed to unmarshal: %v", err)
} }
@ -449,13 +449,13 @@ func TestEdgeCases(t *testing.T) {
}, },
} }
data, err := json.Marshal(msg) data, err := jsonv2.Marshal(msg)
if err != nil { if err != nil {
t.Fatalf("failed to marshal: %v", err) t.Fatalf("failed to marshal: %v", err)
} }
var decoded Message var decoded Message
if err := json.Unmarshal(data, &decoded); err != nil { if err := jsonv2.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err) t.Fatalf("failed to unmarshal: %v", err)
} }
@ -478,13 +478,13 @@ func TestEdgeCases(t *testing.T) {
}, },
} }
data, err := json.Marshal(msg) data, err := jsonv2.Marshal(msg)
if err != nil { if err != nil {
t.Fatalf("failed to marshal: %v", err) t.Fatalf("failed to marshal: %v", err)
} }
var decoded Message var decoded Message
if err := json.Unmarshal(data, &decoded); err != nil { if err := jsonv2.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err) t.Fatalf("failed to unmarshal: %v", err)
} }
@ -502,13 +502,13 @@ func TestEdgeCases(t *testing.T) {
}, },
} }
data, err := json.Marshal(msg) data, err := jsonv2.Marshal(msg)
if err != nil { if err != nil {
t.Fatalf("failed to marshal: %v", err) t.Fatalf("failed to marshal: %v", err)
} }
var decoded Message var decoded Message
if err := json.Unmarshal(data, &decoded); err != nil { if err := jsonv2.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err) t.Fatalf("failed to unmarshal: %v", err)
} }
@ -533,7 +533,7 @@ func TestInvalidJSONHandling(t *testing.T) {
}` }`
var msg Message var msg Message
err := json.Unmarshal([]byte(invalidJSON), &msg) err := jsonv2.Unmarshal([]byte(invalidJSON), &msg)
if err == nil { if err == nil {
t.Error("expected error for unknown message part type, got nil") t.Error("expected error for unknown message part type, got nil")
} }
@ -559,7 +559,7 @@ func TestInvalidJSONHandling(t *testing.T) {
}` }`
var msg Message var msg Message
err := json.Unmarshal([]byte(invalidJSON), &msg) err := jsonv2.Unmarshal([]byte(invalidJSON), &msg)
if err == nil { if err == nil {
t.Error("expected error for unknown tool result output type, got nil") t.Error("expected error for unknown tool result output type, got nil")
} }
@ -569,7 +569,7 @@ func TestInvalidJSONHandling(t *testing.T) {
invalidJSON := `{"role": "user", "content": [` invalidJSON := `{"role": "user", "content": [`
var msg Message var msg Message
err := json.Unmarshal([]byte(invalidJSON), &msg) err := jsonv2.Unmarshal([]byte(invalidJSON), &msg)
if err == nil { if err == nil {
t.Error("expected error for malformed JSON, got nil") t.Error("expected error for malformed JSON, got nil")
} }
@ -584,7 +584,7 @@ type mockProviderData struct {
func (m mockProviderData) Options() {} func (m mockProviderData) Options() {}
func (m mockProviderData) Type() string { return "mock" } func (m mockProviderData) Type() string { return "mock" }
func (m mockProviderData) MarshalJSON() ([]byte, error) { func (m mockProviderData) MarshalJSON() ([]byte, error) {
return json.Marshal(struct { return jsonv2.Marshal(struct {
Type string `json:"type"` Type string `json:"type"`
mockProviderData mockProviderData
}{ }{
@ -598,7 +598,7 @@ func (m *mockProviderData) UnmarshalJSON(data []byte) error {
Type string `json:"type"` Type string `json:"type"`
mockProviderData mockProviderData
} }
if err := json.Unmarshal(data, &aux); err != nil { if err := jsonv2.Unmarshal(data, &aux); err != nil {
return err return err
} }
*m = aux.mockProviderData *m = aux.mockProviderData
@ -618,13 +618,13 @@ func TestPromptSerialization(t *testing.T) {
}, },
} }
data, err := json.Marshal(prompt) data, err := jsonv2.Marshal(prompt)
if err != nil { if err != nil {
t.Fatalf("failed to marshal prompt: %v", err) t.Fatalf("failed to marshal prompt: %v", err)
} }
var decoded Prompt var decoded Prompt
if err := json.Unmarshal(data, &decoded); err != nil { if err := jsonv2.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal prompt: %v", err) t.Fatalf("failed to unmarshal prompt: %v", err)
} }
@ -672,14 +672,14 @@ func TestStreamPartErrorSerialization(t *testing.T) {
} }
// Marshal the stream part // Marshal the stream part
data, err := json.Marshal(streamPart) data, err := jsonv2.Marshal(streamPart)
if err != nil { if err != nil {
t.Fatalf("failed to marshal stream part: %v", err) t.Fatalf("failed to marshal stream part: %v", err)
} }
// Unmarshal back // Unmarshal back
var decoded StreamPart var decoded StreamPart
err = json.Unmarshal(data, &decoded) err = jsonv2.Unmarshal(data, &decoded)
if err != nil { if err != nil {
t.Fatalf("failed to unmarshal stream part: %v", err) t.Fatalf("failed to unmarshal stream part: %v", err)
} }
@ -728,7 +728,7 @@ func TestStreamPartErrorSerialization(t *testing.T) {
}` }`
var streamPart StreamPart var streamPart StreamPart
err := json.Unmarshal([]byte(jsonData), &streamPart) err := jsonv2.Unmarshal([]byte(jsonData), &streamPart)
if err != nil { if err != nil {
t.Fatalf("failed to unmarshal stream part: %v", err) t.Fatalf("failed to unmarshal stream part: %v", err)
} }

View file

@ -3,7 +3,6 @@ package jsonrepair
import ( import (
"bytes" "bytes"
"encoding/json"
"errors" "errors"
"reflect" "reflect"
"slices" "slices"
@ -1521,7 +1520,7 @@ func normalizeValue(value any) any {
} }
return items return items
case numberValue: case numberValue:
return json.Number(v.raw) return v
default: default:
return v return v
} }
@ -1541,8 +1540,6 @@ func writeValue(buf *bytes.Buffer, value any, ensureASCII bool) {
buf.WriteByte('"') buf.WriteByte('"')
case numberValue: case numberValue:
buf.WriteString(v.raw) buf.WriteString(v.raw)
case json.Number:
buf.WriteString(v.String())
case bool: case bool:
if v { if v {
buf.WriteString("true") buf.WriteString("true")

View file

@ -1,7 +1,6 @@
package jsonrepair package jsonrepair
import ( import (
"encoding/json"
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
@ -227,7 +226,7 @@ func TestLoads(t *testing.T) {
input: "{\"name\": \"John\", \"age\": 30, \"city\": \"New York\"}", input: "{\"name\": \"John\", \"age\": 30, \"city\": \"New York\"}",
want: map[string]any{ want: map[string]any{
"name": "John", "name": "John",
"age": json.Number("30"), "age": numberValue{raw: "30"},
"city": "New York", "city": "New York",
}, },
}, },
@ -235,10 +234,10 @@ func TestLoads(t *testing.T) {
name: "array_numbers", name: "array_numbers",
input: "[1, 2, 3, 4]", input: "[1, 2, 3, 4]",
want: []any{ want: []any{
json.Number("1"), numberValue{raw: "1"},
json.Number("2"), numberValue{raw: "2"},
json.Number("3"), numberValue{raw: "3"},
json.Number("4"), numberValue{raw: "4"},
}, },
}, },
{ {
@ -453,10 +452,10 @@ func TestParseArrayObjects(t *testing.T) {
name: "numbers_array", name: "numbers_array",
input: "[1, 2, 3, 4]", input: "[1, 2, 3, 4]",
want: []any{ want: []any{
json.Number("1"), numberValue{raw: "1"},
json.Number("2"), numberValue{raw: "2"},
json.Number("3"), numberValue{raw: "3"},
json.Number("4"), numberValue{raw: "4"},
}, },
}, },
{ {
@ -721,25 +720,25 @@ func TestParseNumber(t *testing.T) {
{ {
name: "integer", name: "integer",
input: "1", input: "1",
want: json.Number("1"), want: numberValue{raw: "1"},
}, },
{ {
name: "float", name: "float",
input: "1.2", input: "1.2",
want: json.Number("1.2"), want: numberValue{raw: "1.2"},
}, },
{ {
name: "underscored_integer", name: "underscored_integer",
input: "{\"value\": 82_461_110}", input: "{\"value\": 82_461_110}",
want: map[string]any{ want: map[string]any{
"value": json.Number("82461110"), "value": numberValue{raw: "82461110"},
}, },
}, },
{ {
name: "underscored_float", name: "underscored_float",
input: "{\"value\": 1_234.5_6}", input: "{\"value\": 1_234.5_6}",
want: map[string]any{ want: map[string]any{
"value": json.Number("1234.56"), "value": numberValue{raw: "1234.56"},
}, },
}, },
} }
@ -879,7 +878,7 @@ func TestParseObjectObjects(t *testing.T) {
input: "{ \"key\": \"value\", \"key2\": 1, \"key3\": True }", input: "{ \"key\": \"value\", \"key2\": 1, \"key3\": True }",
want: map[string]any{ want: map[string]any{
"key": "value", "key": "value",
"key2": json.Number("1"), "key2": numberValue{raw: "1"},
"key3": true, "key3": true,
}, },
}, },
@ -893,7 +892,7 @@ func TestParseObjectObjects(t *testing.T) {
input: "{ \"key\": value, \"key2\": 1 \"key3\": null }", input: "{ \"key\": value, \"key2\": 1 \"key3\": null }",
want: map[string]any{ want: map[string]any{
"key": "value", "key": "value",
"key2": json.Number("1"), "key2": numberValue{raw: "1"},
"key3": nil, "key3": nil,
}, },
}, },

View file

@ -1,26 +1,27 @@
package fantasy package fantasy
import ( import (
"encoding/json"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
) )
// UnmarshalJSON implements json.Unmarshaler for Call. // UnmarshalJSONFrom implements jsonv2.UnmarshalerFrom for Call.
func (c *Call) UnmarshalJSON(data []byte) error { func (c *Call) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
var aux struct { var aux struct {
Prompt Prompt `json:"prompt"` Prompt Prompt `json:"prompt"`
MaxOutputTokens *int64 `json:"max_output_tokens"` MaxOutputTokens *int64 `json:"max_output_tokens"`
Temperature *float64 `json:"temperature"` Temperature *float64 `json:"temperature"`
TopP *float64 `json:"top_p"` TopP *float64 `json:"top_p"`
TopK *int64 `json:"top_k"` TopK *int64 `json:"top_k"`
PresencePenalty *float64 `json:"presence_penalty"` PresencePenalty *float64 `json:"presence_penalty"`
FrequencyPenalty *float64 `json:"frequency_penalty"` FrequencyPenalty *float64 `json:"frequency_penalty"`
Tools []json.RawMessage `json:"tools"` Tools []jsontext.Value `json:"tools"`
ToolChoice *ToolChoice `json:"tool_choice"` ToolChoice *ToolChoice `json:"tool_choice"`
ProviderOptions map[string]json.RawMessage `json:"provider_options"` ProviderOptions map[string]jsontext.Value `json:"provider_options"`
} }
if err := json.Unmarshal(data, &aux); err != nil { if err := jsonv2.UnmarshalDecode(dec, &aux); err != nil {
return err return err
} }
@ -36,7 +37,7 @@ func (c *Call) UnmarshalJSON(data []byte) error {
// Unmarshal Tools slice // Unmarshal Tools slice
c.Tools = make([]Tool, len(aux.Tools)) c.Tools = make([]Tool, len(aux.Tools))
for i, rawTool := range aux.Tools { for i, rawTool := range aux.Tools {
tool, err := UnmarshalTool(rawTool) tool, err := UnmarshalTool([]byte(rawTool))
if err != nil { if err != nil {
return fmt.Errorf("failed to unmarshal tool at index %d: %w", i, err) return fmt.Errorf("failed to unmarshal tool at index %d: %w", i, err)
} }
@ -55,17 +56,17 @@ func (c *Call) UnmarshalJSON(data []byte) error {
return nil return nil
} }
// UnmarshalJSON implements json.Unmarshaler for Response. // UnmarshalJSONFrom implements jsonv2.UnmarshalerFrom for Response.
func (r *Response) UnmarshalJSON(data []byte) error { func (r *Response) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
var aux struct { var aux struct {
Content json.RawMessage `json:"content"` Content jsontext.Value `json:"content"`
FinishReason FinishReason `json:"finish_reason"` FinishReason FinishReason `json:"finish_reason"`
Usage Usage `json:"usage"` Usage Usage `json:"usage"`
Warnings []CallWarning `json:"warnings"` Warnings []CallWarning `json:"warnings"`
ProviderMetadata map[string]json.RawMessage `json:"provider_metadata"` ProviderMetadata map[string]jsontext.Value `json:"provider_metadata"`
} }
if err := json.Unmarshal(data, &aux); err != nil { if err := jsonv2.UnmarshalDecode(dec, &aux); err != nil {
return err return err
} }
@ -73,16 +74,14 @@ func (r *Response) UnmarshalJSON(data []byte) error {
r.Usage = aux.Usage r.Usage = aux.Usage
r.Warnings = aux.Warnings r.Warnings = aux.Warnings
// Unmarshal ResponseContent (need to know the type definition) var rawContent []jsontext.Value
// If ResponseContent is []Content: if err := jsonv2.Unmarshal([]byte(aux.Content), &rawContent); err != nil {
var rawContent []json.RawMessage
if err := json.Unmarshal(aux.Content, &rawContent); err != nil {
return err return err
} }
content := make([]Content, len(rawContent)) content := make([]Content, len(rawContent))
for i, rawItem := range rawContent { for i, rawItem := range rawContent {
item, err := UnmarshalContent(rawItem) item, err := UnmarshalContent([]byte(rawItem))
if err != nil { if err != nil {
return fmt.Errorf("failed to unmarshal content at index %d: %w", i, err) return fmt.Errorf("failed to unmarshal content at index %d: %w", i, err)
} }
@ -102,48 +101,44 @@ func (r *Response) UnmarshalJSON(data []byte) error {
return nil return nil
} }
// MarshalJSON implements json.Marshaler for StreamPart. // MarshalJSONTo implements jsonv2.MarshalerTo for StreamPart.
func (s StreamPart) MarshalJSON() ([]byte, error) { func (s StreamPart) MarshalJSONTo(enc *jsontext.Encoder) error {
type alias StreamPart type alias StreamPart
aux := struct { aux := struct {
alias alias
Error string `json:"error,omitempty"` Error string `json:"error,omitzero"`
}{ }{
alias: (alias)(s), alias: (alias)(s),
} }
// Marshal error to string
if s.Error != nil { if s.Error != nil {
aux.Error = s.Error.Error() aux.Error = s.Error.Error()
} }
// Clear the original Error field to avoid duplicate marshaling
aux.alias.Error = nil aux.alias.Error = nil
return json.Marshal(aux) return jsonv2.MarshalEncode(enc, aux)
} }
// UnmarshalJSON implements json.Unmarshaler for StreamPart. // UnmarshalJSONFrom implements jsonv2.UnmarshalerFrom for StreamPart.
func (s *StreamPart) UnmarshalJSON(data []byte) error { func (s *StreamPart) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
type alias StreamPart type alias StreamPart
aux := struct { aux := struct {
*alias *alias
Error string `json:"error"` Error string `json:"error"`
ProviderMetadata map[string]json.RawMessage `json:"provider_metadata"` ProviderMetadata map[string]jsontext.Value `json:"provider_metadata"`
}{ }{
alias: (*alias)(s), alias: (*alias)(s),
} }
if err := json.Unmarshal(data, &aux); err != nil { if err := jsonv2.UnmarshalDecode(dec, &aux); err != nil {
return err return err
} }
// Unmarshal error string back to error type
if aux.Error != "" { if aux.Error != "" {
s.Error = fmt.Errorf("%s", aux.Error) s.Error = fmt.Errorf("%s", aux.Error)
} }
// Unmarshal ProviderMetadata
if len(aux.ProviderMetadata) > 0 { if len(aux.ProviderMetadata) > 0 {
metadata, err := UnmarshalProviderMetadata(aux.ProviderMetadata) metadata, err := UnmarshalProviderMetadata(aux.ProviderMetadata)
if err != nil { if err != nil {

View file

@ -2,8 +2,8 @@ package fantasy
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"iter" "iter"
"reflect" "reflect"
@ -175,7 +175,7 @@ func (s *StreamObjectResult[T]) Object() (*ObjectResult[T], error) {
if part.Object != nil { if part.Object != nil {
if err := unmarshalObject(part.Object, &finalObject); err == nil { if err := unmarshalObject(part.Object, &finalObject); err == nil {
hasObject = true hasObject = true
if jsonBytes, err := json.Marshal(part.Object); err == nil { if jsonBytes, err := jsonv2.Marshal(part.Object); err == nil {
rawText = string(jsonBytes) rawText = string(jsonBytes)
} }
} }
@ -220,12 +220,12 @@ func (s *StreamObjectResult[T]) Object() (*ObjectResult[T], error) {
} }
func unmarshalObject(obj any, target any) error { func unmarshalObject(obj any, target any) error {
jsonBytes, err := json.Marshal(obj) jsonBytes, err := jsonv2.Marshal(obj)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal object: %w", err) return fmt.Errorf("failed to marshal object: %w", err)
} }
if err := json.Unmarshal(jsonBytes, target); err != nil { if err := jsonv2.Unmarshal(jsonBytes, target); err != nil {
return fmt.Errorf("failed to unmarshal into target type: %w", err) return fmt.Errorf("failed to unmarshal into target type: %w", err)
} }

View file

@ -4,8 +4,8 @@ package object
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"reflect" "reflect"
"charm.land/fantasy" "charm.land/fantasy"
@ -171,7 +171,7 @@ func GenerateWithText(
model fantasy.LanguageModel, model fantasy.LanguageModel,
call fantasy.ObjectCall, call fantasy.ObjectCall,
) (*fantasy.ObjectResponse, error) { ) (*fantasy.ObjectResponse, error) {
jsonSchemaBytes, err := json.Marshal(call.Schema) jsonSchemaBytes, err := jsonv2.Marshal(call.Schema)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to marshal schema: %w", err) return nil, fmt.Errorf("failed to marshal schema: %w", err)
} }
@ -462,7 +462,7 @@ func StreamWithText(
call fantasy.ObjectCall, call fantasy.ObjectCall,
) (fantasy.ObjectStreamResponse, error) { ) (fantasy.ObjectStreamResponse, error) {
jsonSchemaMap := schema.ToMap(call.Schema) jsonSchemaMap := schema.ToMap(call.Schema)
jsonSchemaBytes, err := json.Marshal(jsonSchemaMap) jsonSchemaBytes, err := jsonv2.Marshal(jsonSchemaMap)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to marshal schema: %w", err) return nil, fmt.Errorf("failed to marshal schema: %w", err)
} }
@ -603,12 +603,12 @@ func StreamWithText(
} }
func unmarshal(obj any, target any) error { func unmarshal(obj any, target any) error {
jsonBytes, err := json.Marshal(obj) jsonBytes, err := jsonv2.Marshal(obj)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal object: %w", err) return fmt.Errorf("failed to marshal object: %w", err)
} }
if err := json.Unmarshal(jsonBytes, target); err != nil { if err := jsonv2.Unmarshal(jsonBytes, target); err != nil {
return fmt.Errorf("failed to unmarshal into target type: %w", err) return fmt.Errorf("failed to unmarshal into target type: %w", err)
} }

View file

@ -1,15 +1,16 @@
package fantasy package fantasy
import ( import (
"encoding/json"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"sync" "sync"
) )
// providerDataJSON is the serialized wrapper used by the registry. // providerDataJSON is the serialized wrapper used by the registry.
type providerDataJSON struct { type providerDataJSON struct {
Type string `json:"type"` Type string `json:"type"`
Data json.RawMessage `json:"data"` Data jsontext.Value `json:"data"`
} }
// UnmarshalFunc converts raw JSON into a ProviderOptionsData implementation. // UnmarshalFunc converts raw JSON into a ProviderOptionsData implementation.
@ -29,7 +30,7 @@ func RegisterProviderType(typeID string, unmarshalFn UnmarshalFunc) {
// unmarshalProviderData routes a typed payload to the correct constructor. // unmarshalProviderData routes a typed payload to the correct constructor.
func unmarshalProviderData(data []byte) (ProviderOptionsData, error) { func unmarshalProviderData(data []byte) (ProviderOptionsData, error) {
var pj providerDataJSON var pj providerDataJSON
if err := json.Unmarshal(data, &pj); err != nil { if err := jsonv2.Unmarshal(data, &pj); err != nil {
return nil, err return nil, err
} }
@ -43,7 +44,7 @@ func unmarshalProviderData(data []byte) (ProviderOptionsData, error) {
} }
// unmarshalProviderDataMap is a helper for unmarshaling maps of provider data. // unmarshalProviderDataMap is a helper for unmarshaling maps of provider data.
func unmarshalProviderDataMap(data map[string]json.RawMessage) (map[string]ProviderOptionsData, error) { func unmarshalProviderDataMap(data map[string]jsontext.Value) (map[string]ProviderOptionsData, error) {
result := make(map[string]ProviderOptionsData) result := make(map[string]ProviderOptionsData)
for provider, rawData := range data { for provider, rawData := range data {
providerData, err := unmarshalProviderData(rawData) providerData, err := unmarshalProviderData(rawData)
@ -56,12 +57,12 @@ func unmarshalProviderDataMap(data map[string]json.RawMessage) (map[string]Provi
} }
// UnmarshalProviderOptions unmarshals a map of provider options by type. // UnmarshalProviderOptions unmarshals a map of provider options by type.
func UnmarshalProviderOptions(data map[string]json.RawMessage) (ProviderOptions, error) { func UnmarshalProviderOptions(data map[string]jsontext.Value) (ProviderOptions, error) {
return unmarshalProviderDataMap(data) return unmarshalProviderDataMap(data)
} }
// UnmarshalProviderMetadata unmarshals a map of provider metadata by type. // UnmarshalProviderMetadata unmarshals a map of provider metadata by type.
func UnmarshalProviderMetadata(data map[string]json.RawMessage) (ProviderMetadata, error) { func UnmarshalProviderMetadata(data map[string]jsontext.Value) (ProviderMetadata, error) {
return unmarshalProviderDataMap(data) return unmarshalProviderDataMap(data)
} }
@ -75,14 +76,14 @@ func UnmarshalProviderMetadata(data map[string]json.RawMessage) (ProviderMetadat
// return fantasy.MarshalProviderType(TypeProviderOptions, plain(o)) // return fantasy.MarshalProviderType(TypeProviderOptions, plain(o))
// } // }
func MarshalProviderType[T any](typeID string, data T) ([]byte, error) { func MarshalProviderType[T any](typeID string, data T) ([]byte, error) {
rawData, err := json.Marshal(data) rawData, err := jsonv2.Marshal(data)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return json.Marshal(providerDataJSON{ return jsonv2.Marshal(providerDataJSON{
Type: typeID, Type: typeID,
Data: json.RawMessage(rawData), Data: jsontext.Value(rawData),
}) })
} }
@ -102,5 +103,5 @@ func MarshalProviderType[T any](typeID string, data T) ([]byte, error) {
// return nil // return nil
// } // }
func UnmarshalProviderType[T any](data []byte, target *T) error { func UnmarshalProviderType[T any](data []byte, target *T) error {
return json.Unmarshal(data, target) return jsonv2.Unmarshal(data, target)
} }

View file

@ -5,9 +5,9 @@ import (
"cmp" "cmp"
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"io" "io"
"maps" "maps"
"strings" "strings"
@ -777,7 +777,7 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl
// in the Anthropic assistant message; the ProviderExecuted flag // in the Anthropic assistant message; the ProviderExecuted flag
// is only informational metadata about who ran the tool. // is only informational metadata about who ran the tool.
var inputMap map[string]any var inputMap map[string]any
if err := json.Unmarshal([]byte(toolCall.Input), &inputMap); err != nil { if err := jsonv2.Unmarshal([]byte(toolCall.Input), &inputMap); err != nil {
continue continue
} }
toolUseBlock := anthropic.NewToolUseBlock(toolCall.ToolCallID, inputMap, toolCall.ToolName) toolUseBlock := anthropic.NewToolUseBlock(toolCall.ToolCallID, inputMap, toolCall.ToolName)

View file

@ -2,7 +2,7 @@
package anthropic package anthropic
import ( import (
"encoding/json" jsonv2 "github.com/go-json-experiment/json"
"charm.land/fantasy" "charm.land/fantasy"
) )
@ -18,21 +18,21 @@ const (
func init() { func init() {
fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) { fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ProviderOptions var v ProviderOptions
if err := json.Unmarshal(data, &v); err != nil { if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err return nil, err
} }
return &v, nil return &v, nil
}) })
fantasy.RegisterProviderType(TypeReasoningOptionMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) { fantasy.RegisterProviderType(TypeReasoningOptionMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ReasoningOptionMetadata var v ReasoningOptionMetadata
if err := json.Unmarshal(data, &v); err != nil { if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err return nil, err
} }
return &v, nil return &v, nil
}) })
fantasy.RegisterProviderType(TypeProviderCacheControl, func(data []byte) (fantasy.ProviderOptionsData, error) { fantasy.RegisterProviderType(TypeProviderCacheControl, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ProviderCacheControlOptions var v ProviderCacheControlOptions
if err := json.Unmarshal(data, &v); err != nil { if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err return nil, err
} }
return &v, nil return &v, nil

View file

@ -3,9 +3,9 @@ package google
import ( import (
"cmp" "cmp"
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"maps" "maps"
"net/http" "net/http"
"reflect" "reflect"
@ -428,7 +428,7 @@ func toGooglePrompt(prompt fantasy.Prompt) (*genai.Content, []*genai.Content, []
} }
var result map[string]any var result map[string]any
err := json.Unmarshal([]byte(toolCall.Input), &result) err := jsonv2.Unmarshal([]byte(toolCall.Input), &result)
if err != nil { if err != nil {
continue continue
} }
@ -750,7 +750,7 @@ func (g *languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.
return return
} }
} }
args, err := json.Marshal(part.FunctionCall.Args) args, err := jsonv2.Marshal(part.FunctionCall.Args)
if err != nil { if err != nil {
yield(fantasy.StreamPart{ yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeError, Type: fantasy.StreamPartTypeError,
@ -1380,7 +1380,7 @@ func (g languageModel) mapResponse(response *genai.GenerateContentResponse, warn
content = append(content, fantasy.TextContent{Text: part.Text}) content = append(content, fantasy.TextContent{Text: part.Text})
} }
case part.FunctionCall != nil: case part.FunctionCall != nil:
input, err := json.Marshal(part.FunctionCall.Args) input, err := jsonv2.Marshal(part.FunctionCall.Args)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -2,7 +2,7 @@
package google package google
import ( import (
"encoding/json" jsonv2 "github.com/go-json-experiment/json"
"charm.land/fantasy" "charm.land/fantasy"
) )
@ -17,14 +17,14 @@ const (
func init() { func init() {
fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) { fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ProviderOptions var v ProviderOptions
if err := json.Unmarshal(data, &v); err != nil { if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err return nil, err
} }
return &v, nil return &v, nil
}) })
fantasy.RegisterProviderType(TypeReasoningMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) { fantasy.RegisterProviderType(TypeReasoningMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ReasoningMetadata var v ReasoningMetadata
if err := json.Unmarshal(data, &v); err != nil { if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err return nil, err
} }
return &v, nil return &v, nil

View file

@ -2,9 +2,9 @@ package openai
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"io" "io"
"reflect" "reflect"
"strings" "strings"
@ -642,7 +642,7 @@ func parseAnnotationsFromDelta(delta openai.ChatCompletionChunkChoiceDelta) []op
// Parse the raw JSON to extract annotations // Parse the raw JSON to extract annotations
var deltaData map[string]any var deltaData map[string]any
if err := json.Unmarshal([]byte(delta.RawJSON()), &deltaData); err != nil { if err := jsonv2.Unmarshal([]byte(delta.RawJSON()), &deltaData); err != nil {
return annotations return annotations
} }

View file

@ -3,7 +3,6 @@ package openai
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json"
"errors" "errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@ -11,6 +10,7 @@ import (
"testing" "testing"
"charm.land/fantasy" "charm.land/fantasy"
jsonv2 "github.com/go-json-experiment/json"
"github.com/openai/openai-go/v2/packages/param" "github.com/openai/openai-go/v2/packages/param"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -427,10 +427,10 @@ func TestToOpenAiPrompt_ToolCalls(t *testing.T) {
t.Parallel() t.Parallel()
inputArgs := map[string]any{"foo": "bar123"} inputArgs := map[string]any{"foo": "bar123"}
inputJSON, _ := json.Marshal(inputArgs) inputJSON, _ := jsonv2.Marshal(inputArgs)
outputResult := map[string]any{"oof": "321rab"} outputResult := map[string]any{"oof": "321rab"}
outputJSON, _ := json.Marshal(outputResult) outputJSON, _ := jsonv2.Marshal(outputResult)
prompt := fantasy.Prompt{ prompt := fantasy.Prompt{
{ {
@ -551,7 +551,7 @@ func TestToOpenAiPrompt_AssistantMessages(t *testing.T) {
t.Parallel() t.Parallel()
inputArgs := map[string]any{"query": "test"} inputArgs := map[string]any{"query": "test"}
inputJSON, _ := json.Marshal(inputArgs) inputJSON, _ := jsonv2.Marshal(inputArgs)
prompt := fantasy.Prompt{ prompt := fantasy.Prompt{
{ {
@ -723,7 +723,7 @@ func newMockServer() *mockServer {
// Parse request body // Parse request body
if r.Body != nil { if r.Body != nil {
var body map[string]any var body map[string]any
json.NewDecoder(r.Body).Decode(&body) jsonv2.UnmarshalRead(r.Body, &body)
call.body = body call.body = body
} }
@ -731,7 +731,7 @@ func newMockServer() *mockServer {
// Return mock response // Return mock response
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(ms.response) jsonv2.MarshalWrite(w, ms.response)
})) }))
return ms return ms
@ -2038,7 +2038,7 @@ func newStreamingMockServer() *streamingMockServer {
// Parse request body // Parse request body
if r.Body != nil { if r.Body != nil {
var body map[string]any var body map[string]any
json.NewDecoder(r.Body).Decode(&body) jsonv2.UnmarshalRead(r.Body, &body)
call.body = body call.body = body
} }
@ -2141,7 +2141,7 @@ func (sms *streamingMockServer) prepareStreamResponse(opts map[string]any) {
}, },
}, },
} }
initialData, _ := json.Marshal(initialChunk) initialData, _ := jsonv2.Marshal(initialChunk)
chunks = append(chunks, "data: "+string(initialData)+"\n\n") chunks = append(chunks, "data: "+string(initialData)+"\n\n")
// Content chunks // Content chunks
@ -2162,7 +2162,7 @@ func (sms *streamingMockServer) prepareStreamResponse(opts map[string]any) {
}, },
}, },
} }
contentData, _ := json.Marshal(contentChunk) contentData, _ := jsonv2.Marshal(contentChunk)
chunks = append(chunks, "data: "+string(contentData)+"\n\n") chunks = append(chunks, "data: "+string(contentData)+"\n\n")
// Add annotations if this is the last content chunk and we have annotations // Add annotations if this is the last content chunk and we have annotations
@ -2184,7 +2184,7 @@ func (sms *streamingMockServer) prepareStreamResponse(opts map[string]any) {
}, },
}, },
} }
annotationData, _ := json.Marshal(annotationChunk) annotationData, _ := jsonv2.Marshal(annotationChunk)
chunks = append(chunks, "data: "+string(annotationData)+"\n\n") chunks = append(chunks, "data: "+string(annotationData)+"\n\n")
} }
} }
@ -2210,7 +2210,7 @@ func (sms *streamingMockServer) prepareStreamResponse(opts map[string]any) {
finishChunk["choices"].([]map[string]any)[0]["logprobs"] = logprobs finishChunk["choices"].([]map[string]any)[0]["logprobs"] = logprobs
} }
finishData, _ := json.Marshal(finishChunk) finishData, _ := jsonv2.Marshal(finishChunk)
chunks = append(chunks, "data: "+string(finishData)+"\n\n") chunks = append(chunks, "data: "+string(finishData)+"\n\n")
// Usage chunk // Usage chunk
@ -2223,7 +2223,7 @@ func (sms *streamingMockServer) prepareStreamResponse(opts map[string]any) {
"choices": []map[string]any{}, "choices": []map[string]any{},
"usage": usage, "usage": usage,
} }
usageData, _ := json.Marshal(usageChunk) usageData, _ := jsonv2.Marshal(usageChunk)
chunks = append(chunks, "data: "+string(usageData)+"\n\n") chunks = append(chunks, "data: "+string(usageData)+"\n\n")
// Done // Done

View file

@ -2,7 +2,7 @@
package openai package openai
import ( import (
"encoding/json" jsonv2 "github.com/go-json-experiment/json"
"charm.land/fantasy" "charm.land/fantasy"
"github.com/openai/openai-go/v2" "github.com/openai/openai-go/v2"
@ -33,21 +33,21 @@ const (
func init() { func init() {
fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) { fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ProviderOptions var v ProviderOptions
if err := json.Unmarshal(data, &v); err != nil { if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err return nil, err
} }
return &v, nil return &v, nil
}) })
fantasy.RegisterProviderType(TypeProviderFileOptions, func(data []byte) (fantasy.ProviderOptionsData, error) { fantasy.RegisterProviderType(TypeProviderFileOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ProviderFileOptions var v ProviderFileOptions
if err := json.Unmarshal(data, &v); err != nil { if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err return nil, err
} }
return &v, nil return &v, nil
}) })
fantasy.RegisterProviderType(TypeProviderMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) { fantasy.RegisterProviderType(TypeProviderMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ProviderMetadata var v ProviderMetadata
if err := json.Unmarshal(data, &v); err != nil { if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err return nil, err
} }
return &v, nil return &v, nil

View file

@ -3,8 +3,8 @@ package openai
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"reflect" "reflect"
"strings" "strings"
@ -479,7 +479,7 @@ func toResponsesPrompt(prompt fantasy.Prompt, systemMessageMode string) (respons
continue continue
} }
inputJSON, err := json.Marshal(toolCallPart.Input) inputJSON, err := jsonv2.Marshal(toolCallPart.Input)
if err != nil { if err != nil {
warnings = append(warnings, fantasy.CallWarning{ warnings = append(warnings, fantasy.CallWarning{
Type: fantasy.CallWarningTypeOther, Type: fantasy.CallWarningTypeOther,

View file

@ -2,7 +2,7 @@
package openai package openai
import ( import (
"encoding/json" jsonv2 "github.com/go-json-experiment/json"
"slices" "slices"
"charm.land/fantasy" "charm.land/fantasy"
@ -18,14 +18,14 @@ const (
func init() { func init() {
fantasy.RegisterProviderType(TypeResponsesProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) { fantasy.RegisterProviderType(TypeResponsesProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ResponsesProviderOptions var v ResponsesProviderOptions
if err := json.Unmarshal(data, &v); err != nil { if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err return nil, err
} }
return &v, nil return &v, nil
}) })
fantasy.RegisterProviderType(TypeResponsesReasoningMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) { fantasy.RegisterProviderType(TypeResponsesReasoningMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ResponsesReasoningMetadata var v ResponsesReasoningMetadata
if err := json.Unmarshal(data, &v); err != nil { if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err return nil, err
} }
return &v, nil return &v, nil

View file

@ -2,8 +2,8 @@ package openaicompat
import ( import (
"encoding/base64" "encoding/base64"
"encoding/json"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"strings" "strings"
"charm.land/fantasy" "charm.land/fantasy"
@ -50,7 +50,7 @@ func PrepareCallFunc(_ fantasy.LanguageModel, params *openaisdk.ChatCompletionNe
func ExtraContentFunc(choice openaisdk.ChatCompletionChoice) []fantasy.Content { func ExtraContentFunc(choice openaisdk.ChatCompletionChoice) []fantasy.Content {
var content []fantasy.Content var content []fantasy.Content
reasoningData := ReasoningData{} reasoningData := ReasoningData{}
err := json.Unmarshal([]byte(choice.Message.RawJSON()), &reasoningData) err := jsonv2.Unmarshal([]byte(choice.Message.RawJSON()), &reasoningData)
if err != nil { if err != nil {
return content return content
} }
@ -84,7 +84,7 @@ func StreamExtraFunc(chunk openaisdk.ChatCompletionChunk, yield func(fantasy.Str
for inx, choice := range chunk.Choices { for inx, choice := range chunk.Choices {
reasoningData := ReasoningData{} reasoningData := ReasoningData{}
err := json.Unmarshal([]byte(choice.Delta.RawJSON()), &reasoningData) err := jsonv2.Unmarshal([]byte(choice.Delta.RawJSON()), &reasoningData)
if err != nil { if err != nil {
yield(fantasy.StreamPart{ yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeError, Type: fantasy.StreamPartTypeError,

View file

@ -2,7 +2,7 @@
package openaicompat package openaicompat
import ( import (
"encoding/json" jsonv2 "github.com/go-json-experiment/json"
"charm.land/fantasy" "charm.land/fantasy"
"charm.land/fantasy/providers/openai" "charm.land/fantasy/providers/openai"
@ -17,7 +17,7 @@ const (
func init() { func init() {
fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) { fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ProviderOptions var v ProviderOptions
if err := json.Unmarshal(data, &v); err != nil { if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err return nil, err
} }
return &v, nil return &v, nil

View file

@ -2,8 +2,8 @@ package openrouter
import ( import (
"encoding/base64" "encoding/base64"
"encoding/json"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"maps" "maps"
"strings" "strings"
@ -74,7 +74,7 @@ func languagePrepareModelCall(_ fantasy.LanguageModel, params *openaisdk.ChatCom
func languageModelExtraContent(choice openaisdk.ChatCompletionChoice) []fantasy.Content { func languageModelExtraContent(choice openaisdk.ChatCompletionChoice) []fantasy.Content {
content := make([]fantasy.Content, 0) content := make([]fantasy.Content, 0)
reasoningData := ReasoningData{} reasoningData := ReasoningData{}
err := json.Unmarshal([]byte(choice.Message.RawJSON()), &reasoningData) err := jsonv2.Unmarshal([]byte(choice.Message.RawJSON()), &reasoningData)
if err != nil { if err != nil {
return content return content
} }
@ -212,7 +212,7 @@ func languageModelStreamExtra(chunk openaisdk.ChatCompletionChunk, yield func(fa
inx := 0 inx := 0
choice := chunk.Choices[inx] choice := chunk.Choices[inx]
reasoningData := ReasoningData{} reasoningData := ReasoningData{}
err := json.Unmarshal([]byte(choice.Delta.RawJSON()), &reasoningData) err := jsonv2.Unmarshal([]byte(choice.Delta.RawJSON()), &reasoningData)
if err != nil { if err != nil {
yield(fantasy.StreamPart{ yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeError, Type: fantasy.StreamPartTypeError,
@ -423,7 +423,7 @@ func languageModelUsage(response openaisdk.ChatCompletion) (fantasy.Usage, fanta
openrouterUsage := UsageAccounting{} openrouterUsage := UsageAccounting{}
usage := response.Usage usage := response.Usage
_ = json.Unmarshal([]byte(usage.RawJSON()), &openrouterUsage) _ = jsonv2.Unmarshal([]byte(usage.RawJSON()), &openrouterUsage)
completionTokenDetails := usage.CompletionTokensDetails completionTokenDetails := usage.CompletionTokensDetails
promptTokenDetails := usage.PromptTokensDetails promptTokenDetails := usage.PromptTokensDetails
@ -464,7 +464,7 @@ func languageModelStreamUsage(chunk openaisdk.ChatCompletionChunk, _ map[string]
} }
} }
openrouterUsage := UsageAccounting{} openrouterUsage := UsageAccounting{}
_ = json.Unmarshal([]byte(usage.RawJSON()), &openrouterUsage) _ = jsonv2.Unmarshal([]byte(usage.RawJSON()), &openrouterUsage)
streamProviderMetadata.Usage = openrouterUsage streamProviderMetadata.Usage = openrouterUsage
if p, ok := chunk.JSON.ExtraFields["provider"]; ok { if p, ok := chunk.JSON.ExtraFields["provider"]; ok {
@ -810,9 +810,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk.
Text: reasoningPart.Text, Text: reasoningPart.Text,
Signature: metadata.Signature, Signature: metadata.Signature,
}) })
data, _ := json.Marshal(reasoningDetails) data, _ := jsonv2.Marshal(reasoningDetails)
reasoningDetailsMap := []map[string]any{} reasoningDetailsMap := []map[string]any{}
_ = json.Unmarshal(data, &reasoningDetailsMap) _ = jsonv2.Unmarshal(data, &reasoningDetailsMap)
assistantMsg.SetExtraFields(map[string]any{ assistantMsg.SetExtraFields(map[string]any{
"reasoning_details": reasoningDetailsMap, "reasoning_details": reasoningDetailsMap,
"reasoning": reasoningPart.Text, "reasoning": reasoningPart.Text,
@ -847,9 +847,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk.
Data: *metadata.EncryptedContent, Data: *metadata.EncryptedContent,
ID: metadata.ItemID, ID: metadata.ItemID,
}) })
data, _ := json.Marshal(reasoningDetails) data, _ := jsonv2.Marshal(reasoningDetails)
reasoningDetailsMap := []map[string]any{} reasoningDetailsMap := []map[string]any{}
_ = json.Unmarshal(data, &reasoningDetailsMap) _ = jsonv2.Unmarshal(data, &reasoningDetailsMap)
assistantMsg.SetExtraFields(map[string]any{ assistantMsg.SetExtraFields(map[string]any{
"reasoning_details": reasoningDetailsMap, "reasoning_details": reasoningDetailsMap,
}) })
@ -883,9 +883,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk.
Data: *metadata.EncryptedContent, Data: *metadata.EncryptedContent,
ID: metadata.ItemID, ID: metadata.ItemID,
}) })
data, _ := json.Marshal(reasoningDetails) data, _ := jsonv2.Marshal(reasoningDetails)
reasoningDetailsMap := []map[string]any{} reasoningDetailsMap := []map[string]any{}
_ = json.Unmarshal(data, &reasoningDetailsMap) _ = jsonv2.Unmarshal(data, &reasoningDetailsMap)
assistantMsg.SetExtraFields(map[string]any{ assistantMsg.SetExtraFields(map[string]any{
"reasoning_details": reasoningDetailsMap, "reasoning_details": reasoningDetailsMap,
}) })
@ -915,9 +915,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk.
Data: metadata.Signature, Data: metadata.Signature,
ID: metadata.ToolID, ID: metadata.ToolID,
}) })
data, _ := json.Marshal(reasoningDetails) data, _ := jsonv2.Marshal(reasoningDetails)
reasoningDetailsMap := []map[string]any{} reasoningDetailsMap := []map[string]any{}
_ = json.Unmarshal(data, &reasoningDetailsMap) _ = jsonv2.Unmarshal(data, &reasoningDetailsMap)
assistantMsg.SetExtraFields(map[string]any{ assistantMsg.SetExtraFields(map[string]any{
"reasoning_details": reasoningDetailsMap, "reasoning_details": reasoningDetailsMap,
}) })
@ -927,9 +927,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk.
Text: reasoningPart.Text, Text: reasoningPart.Text,
Format: "unknown", Format: "unknown",
}) })
data, _ := json.Marshal(reasoningDetails) data, _ := jsonv2.Marshal(reasoningDetails)
reasoningDetailsMap := []map[string]any{} reasoningDetailsMap := []map[string]any{}
_ = json.Unmarshal(data, &reasoningDetailsMap) _ = jsonv2.Unmarshal(data, &reasoningDetailsMap)
assistantMsg.SetExtraFields(map[string]any{ assistantMsg.SetExtraFields(map[string]any{
"reasoning_details": reasoningDetailsMap, "reasoning_details": reasoningDetailsMap,
}) })

View file

@ -2,7 +2,7 @@
package openrouter package openrouter
import ( import (
"encoding/json" jsonv2 "github.com/go-json-experiment/json"
"charm.land/fantasy" "charm.land/fantasy"
"charm.land/fantasy/providers/openai" "charm.land/fantasy/providers/openai"
@ -101,11 +101,11 @@ func WithObjectMode(om fantasy.ObjectMode) Option {
func structToMapJSON(s any) (map[string]any, error) { func structToMapJSON(s any) (map[string]any, error) {
var result map[string]any var result map[string]any
jsonBytes, err := json.Marshal(s) jsonBytes, err := jsonv2.Marshal(s)
if err != nil { if err != nil {
return nil, err return nil, err
} }
err = json.Unmarshal(jsonBytes, &result) err = jsonv2.Unmarshal(jsonBytes, &result)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -2,7 +2,7 @@
package openrouter package openrouter
import ( import (
"encoding/json" jsonv2 "github.com/go-json-experiment/json"
"charm.land/fantasy" "charm.land/fantasy"
) )
@ -29,14 +29,14 @@ const (
func init() { func init() {
fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) { fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ProviderOptions var v ProviderOptions
if err := json.Unmarshal(data, &v); err != nil { if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err return nil, err
} }
return &v, nil return &v, nil
}) })
fantasy.RegisterProviderType(TypeProviderMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) { fantasy.RegisterProviderType(TypeProviderMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ProviderMetadata var v ProviderMetadata
if err := json.Unmarshal(data, &v); err != nil { if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err return nil, err
} }
return &v, nil return &v, nil
@ -100,53 +100,53 @@ func (m *ProviderMetadata) UnmarshalJSON(data []byte) error {
// ReasoningOptions represents reasoning options for OpenRouter. // ReasoningOptions represents reasoning options for OpenRouter.
type ReasoningOptions struct { type ReasoningOptions struct {
// Whether reasoning is enabled // Whether reasoning is enabled
Enabled *bool `json:"enabled,omitempty"` Enabled *bool `json:"enabled,omitzero"`
// Whether to exclude reasoning from the response // Whether to exclude reasoning from the response
Exclude *bool `json:"exclude,omitempty"` Exclude *bool `json:"exclude,omitzero"`
// Maximum number of tokens to use for reasoning // Maximum number of tokens to use for reasoning
MaxTokens *int64 `json:"max_tokens,omitempty"` MaxTokens *int64 `json:"max_tokens,omitzero"`
// Reasoning effort level: "low" | "medium" | "high" // Reasoning effort level: "low" | "medium" | "high"
Effort *ReasoningEffort `json:"effort,omitempty"` Effort *ReasoningEffort `json:"effort,omitzero"`
} }
// Provider represents provider routing preferences for OpenRouter. // Provider represents provider routing preferences for OpenRouter.
type Provider struct { type Provider struct {
// List of provider slugs to try in order (e.g. ["anthropic", "openai"]) // List of provider slugs to try in order (e.g. ["anthropic", "openai"])
Order []string `json:"order,omitempty"` Order []string `json:"order,omitzero"`
// Whether to allow backup providers when primary is unavailable (default: true) // Whether to allow backup providers when primary is unavailable (default: true)
AllowFallbacks *bool `json:"allow_fallbacks,omitempty"` AllowFallbacks *bool `json:"allow_fallbacks,omitzero"`
// Only use providers that support all parameters in your request (default: false) // Only use providers that support all parameters in your request (default: false)
RequireParameters *bool `json:"require_parameters,omitempty"` RequireParameters *bool `json:"require_parameters,omitzero"`
// Control whether to use providers that may store data: "allow" | "deny" // Control whether to use providers that may store data: "allow" | "deny"
DataCollection *string `json:"data_collection,omitempty"` DataCollection *string `json:"data_collection,omitzero"`
// List of provider slugs to allow for this request // List of provider slugs to allow for this request
Only []string `json:"only,omitempty"` Only []string `json:"only,omitzero"`
// List of provider slugs to skip for this request // List of provider slugs to skip for this request
Ignore []string `json:"ignore,omitempty"` Ignore []string `json:"ignore,omitzero"`
// List of quantization levels to filter by (e.g. ["int4", "int8"]) // List of quantization levels to filter by (e.g. ["int4", "int8"])
Quantizations []string `json:"quantizations,omitempty"` Quantizations []string `json:"quantizations,omitzero"`
// Sort providers by "price" | "throughput" | "latency" // Sort providers by "price" | "throughput" | "latency"
Sort *string `json:"sort,omitempty"` Sort *string `json:"sort,omitzero"`
} }
// ProviderOptions represents additional options for OpenRouter provider. // ProviderOptions represents additional options for OpenRouter provider.
type ProviderOptions struct { type ProviderOptions struct {
Reasoning *ReasoningOptions `json:"reasoning,omitempty"` Reasoning *ReasoningOptions `json:"reasoning,omitzero"`
ExtraBody map[string]any `json:"extra_body,omitempty"` ExtraBody map[string]any `json:"extra_body,omitzero"`
IncludeUsage *bool `json:"include_usage,omitempty"` IncludeUsage *bool `json:"include_usage,omitzero"`
// Modify the likelihood of specified tokens appearing in the completion. // Modify the likelihood of specified tokens appearing in the completion.
// Accepts a map that maps tokens (specified by their token ID) to an associated bias value from -100 to 100. // Accepts a map that maps tokens (specified by their token ID) to an associated bias value from -100 to 100.
// The bias is added to the logits generated by the model prior to sampling. // The bias is added to the logits generated by the model prior to sampling.
LogitBias map[string]int64 `json:"logit_bias,omitempty"` LogitBias map[string]int64 `json:"logit_bias,omitzero"`
// Return the log probabilities of the tokens. Including logprobs will increase the response size. // Return the log probabilities of the tokens. Including logprobs will increase the response size.
// Setting to true will return the log probabilities of the tokens that were generated. // Setting to true will return the log probabilities of the tokens that were generated.
LogProbs *bool `json:"log_probs,omitempty"` LogProbs *bool `json:"log_probs,omitzero"`
// Whether to enable parallel function calling during tool use. Default to true. // Whether to enable parallel function calling during tool use. Default to true.
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` ParallelToolCalls *bool `json:"parallel_tool_calls,omitzero"`
// A unique identifier representing your end-user, which can help OpenRouter to monitor and detect abuse. // A unique identifier representing your end-user, which can help OpenRouter to monitor and detect abuse.
User *string `json:"user,omitempty"` User *string `json:"user,omitzero"`
// Provider routing preferences to control request routing behavior // Provider routing preferences to control request routing behavior
Provider *Provider `json:"provider,omitempty"` Provider *Provider `json:"provider,omitzero"`
// Plugins is the ordered list of OpenRouter plugins to enable for this // Plugins is the ordered list of OpenRouter plugins to enable for this
// request. Use WebSearchPlugin to activate online search: // request. Use WebSearchPlugin to activate online search:
// //
@ -154,15 +154,15 @@ type ProviderOptions struct {
// //
// Refer to https://openrouter.ai/docs/features/web-search for the full // Refer to https://openrouter.ai/docs/features/web-search for the full
// plugin reference. // plugin reference.
Plugins []Plugin `json:"plugins,omitempty"` Plugins []Plugin `json:"plugins,omitzero"`
} }
// WebSearchPlugin configures the OpenRouter web-search plugin. // WebSearchPlugin configures the OpenRouter web-search plugin.
type WebSearchPlugin struct { type WebSearchPlugin struct {
// MaxResults caps how many search results the plugin returns (0 = provider default). // MaxResults caps how many search results the plugin returns (0 = provider default).
MaxResults int `json:"max_results,omitempty"` MaxResults int `json:"max_results,omitzero"`
// SearchPrompt overrides the system prompt used internally by the plugin. // SearchPrompt overrides the system prompt used internally by the plugin.
SearchPrompt string `json:"search_prompt,omitempty"` SearchPrompt string `json:"search_prompt,omitzero"`
} }
// Plugin represents a single OpenRouter plugin entry. // Plugin represents a single OpenRouter plugin entry.
@ -172,7 +172,7 @@ type Plugin struct {
// ID is the plugin identifier (e.g. "web"). // ID is the plugin identifier (e.g. "web").
ID string `json:"id"` ID string `json:"id"`
// WebSearch holds optional web-search configuration. Omit for defaults. // WebSearch holds optional web-search configuration. Omit for defaults.
WebSearch *WebSearchPlugin `json:"web,omitempty"` WebSearch *WebSearchPlugin `json:"web,omitzero"`
} }
// NewWebSearchPlugin is a convenience constructor that returns a Plugin slice // NewWebSearchPlugin is a convenience constructor that returns a Plugin slice
@ -209,13 +209,13 @@ func (o *ProviderOptions) UnmarshalJSON(data []byte) error {
// ReasoningDetail represents a reasoning detail for OpenRouter. // ReasoningDetail represents a reasoning detail for OpenRouter.
type ReasoningDetail struct { type ReasoningDetail struct {
ID string `json:"id,omitempty"` ID string `json:"id,omitzero"`
Type string `json:"type,omitempty"` Type string `json:"type,omitzero"`
Text string `json:"text,omitempty"` Text string `json:"text,omitzero"`
Data string `json:"data,omitempty"` Data string `json:"data,omitzero"`
Format string `json:"format,omitempty"` Format string `json:"format,omitzero"`
Summary string `json:"summary,omitempty"` Summary string `json:"summary,omitzero"`
Signature string `json:"signature,omitempty"` Signature string `json:"signature,omitzero"`
Index int `json:"index"` Index int `json:"index"`
} }

View file

@ -2,8 +2,8 @@ package vercel
import ( import (
"encoding/base64" "encoding/base64"
"encoding/json"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"maps" "maps"
"strings" "strings"
@ -102,7 +102,7 @@ func languagePrepareModelCall(_ fantasy.LanguageModel, params *openaisdk.ChatCom
func languageModelExtraContent(choice openaisdk.ChatCompletionChoice) []fantasy.Content { func languageModelExtraContent(choice openaisdk.ChatCompletionChoice) []fantasy.Content {
content := make([]fantasy.Content, 0) content := make([]fantasy.Content, 0)
reasoningData := ReasoningData{} reasoningData := ReasoningData{}
err := json.Unmarshal([]byte(choice.Message.RawJSON()), &reasoningData) err := jsonv2.Unmarshal([]byte(choice.Message.RawJSON()), &reasoningData)
if err != nil { if err != nil {
return content return content
} }
@ -251,7 +251,7 @@ func languageModelStreamExtra(chunk openaisdk.ChatCompletionChunk, yield func(fa
inx := 0 inx := 0
choice := chunk.Choices[inx] choice := chunk.Choices[inx]
reasoningData := ReasoningData{} reasoningData := ReasoningData{}
err := json.Unmarshal([]byte(choice.Delta.RawJSON()), &reasoningData) err := jsonv2.Unmarshal([]byte(choice.Delta.RawJSON()), &reasoningData)
if err != nil { if err != nil {
yield(fantasy.StreamPart{ yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeError, Type: fantasy.StreamPartTypeError,
@ -844,9 +844,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk.
Text: reasoningPart.Text, Text: reasoningPart.Text,
Signature: metadata.Signature, Signature: metadata.Signature,
}) })
data, _ := json.Marshal(reasoningDetails) data, _ := jsonv2.Marshal(reasoningDetails)
reasoningDetailsMap := []map[string]any{} reasoningDetailsMap := []map[string]any{}
_ = json.Unmarshal(data, &reasoningDetailsMap) _ = jsonv2.Unmarshal(data, &reasoningDetailsMap)
assistantMsg.SetExtraFields(map[string]any{ assistantMsg.SetExtraFields(map[string]any{
"reasoning_details": reasoningDetailsMap, "reasoning_details": reasoningDetailsMap,
"reasoning": reasoningPart.Text, "reasoning": reasoningPart.Text,
@ -880,9 +880,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk.
Data: *metadata.EncryptedContent, Data: *metadata.EncryptedContent,
ID: metadata.ItemID, ID: metadata.ItemID,
}) })
data, _ := json.Marshal(reasoningDetails) data, _ := jsonv2.Marshal(reasoningDetails)
reasoningDetailsMap := []map[string]any{} reasoningDetailsMap := []map[string]any{}
_ = json.Unmarshal(data, &reasoningDetailsMap) _ = jsonv2.Unmarshal(data, &reasoningDetailsMap)
assistantMsg.SetExtraFields(map[string]any{ assistantMsg.SetExtraFields(map[string]any{
"reasoning_details": reasoningDetailsMap, "reasoning_details": reasoningDetailsMap,
}) })
@ -911,9 +911,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk.
Data: metadata.Signature, Data: metadata.Signature,
ID: metadata.ToolID, ID: metadata.ToolID,
}) })
data, _ := json.Marshal(reasoningDetails) data, _ := jsonv2.Marshal(reasoningDetails)
reasoningDetailsMap := []map[string]any{} reasoningDetailsMap := []map[string]any{}
_ = json.Unmarshal(data, &reasoningDetailsMap) _ = jsonv2.Unmarshal(data, &reasoningDetailsMap)
assistantMsg.SetExtraFields(map[string]any{ assistantMsg.SetExtraFields(map[string]any{
"reasoning_details": reasoningDetailsMap, "reasoning_details": reasoningDetailsMap,
}) })
@ -923,9 +923,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk.
Text: reasoningPart.Text, Text: reasoningPart.Text,
Format: "unknown", Format: "unknown",
}) })
data, _ := json.Marshal(reasoningDetails) data, _ := jsonv2.Marshal(reasoningDetails)
reasoningDetailsMap := []map[string]any{} reasoningDetailsMap := []map[string]any{}
_ = json.Unmarshal(data, &reasoningDetailsMap) _ = jsonv2.Unmarshal(data, &reasoningDetailsMap)
assistantMsg.SetExtraFields(map[string]any{ assistantMsg.SetExtraFields(map[string]any{
"reasoning_details": reasoningDetailsMap, "reasoning_details": reasoningDetailsMap,
}) })
@ -1040,11 +1040,11 @@ func hasVisibleUserContent(content []openaisdk.ChatCompletionContentPartUnionPar
func structToMapJSON(s any) (map[string]any, error) { func structToMapJSON(s any) (map[string]any, error) {
var result map[string]any var result map[string]any
jsonBytes, err := json.Marshal(s) jsonBytes, err := jsonv2.Marshal(s)
if err != nil { if err != nil {
return nil, err return nil, err
} }
err = json.Unmarshal(jsonBytes, &result) err = jsonv2.Unmarshal(jsonBytes, &result)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -2,7 +2,7 @@
package vercel package vercel
import ( import (
"encoding/json" jsonv2 "github.com/go-json-experiment/json"
"charm.land/fantasy" "charm.land/fantasy"
) )
@ -17,14 +17,14 @@ const (
func init() { func init() {
fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) { fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ProviderOptions var v ProviderOptions
if err := json.Unmarshal(data, &v); err != nil { if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err return nil, err
} }
return &v, nil return &v, nil
}) })
fantasy.RegisterProviderType(TypeProviderMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) { fantasy.RegisterProviderType(TypeProviderMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ProviderMetadata var v ProviderMetadata
if err := json.Unmarshal(data, &v); err != nil { if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err return nil, err
} }
return &v, nil return &v, nil
@ -52,58 +52,58 @@ const (
// ReasoningOptions represents reasoning configuration for Vercel AI Gateway. // ReasoningOptions represents reasoning configuration for Vercel AI Gateway.
type ReasoningOptions struct { type ReasoningOptions struct {
// Enabled enables reasoning output. When true, the model will provide its reasoning process. // Enabled enables reasoning output. When true, the model will provide its reasoning process.
Enabled *bool `json:"enabled,omitempty"` Enabled *bool `json:"enabled,omitzero"`
// MaxTokens is the maximum number of tokens to allocate for reasoning. // MaxTokens is the maximum number of tokens to allocate for reasoning.
// Cannot be used with Effort. // Cannot be used with Effort.
MaxTokens *int64 `json:"max_tokens,omitempty"` MaxTokens *int64 `json:"max_tokens,omitzero"`
// Effort controls reasoning effort level. // Effort controls reasoning effort level.
// Mutually exclusive with MaxTokens. // Mutually exclusive with MaxTokens.
Effort *ReasoningEffort `json:"effort,omitempty"` Effort *ReasoningEffort `json:"effort,omitzero"`
// Exclude excludes reasoning content from the response but still generates it internally. // Exclude excludes reasoning content from the response but still generates it internally.
Exclude *bool `json:"exclude,omitempty"` Exclude *bool `json:"exclude,omitzero"`
} }
// GatewayProviderOptions represents provider routing preferences for Vercel AI Gateway. // GatewayProviderOptions represents provider routing preferences for Vercel AI Gateway.
type GatewayProviderOptions struct { type GatewayProviderOptions struct {
// Order is the list of provider slugs to try in order (e.g. ["vertex", "anthropic"]). // Order is the list of provider slugs to try in order (e.g. ["vertex", "anthropic"]).
Order []string `json:"order,omitempty"` Order []string `json:"order,omitzero"`
// Models is the list of fallback models to try if the primary model fails. // Models is the list of fallback models to try if the primary model fails.
Models []string `json:"models,omitempty"` Models []string `json:"models,omitzero"`
} }
// BYOKCredential represents a single provider credential for BYOK. // BYOKCredential represents a single provider credential for BYOK.
type BYOKCredential struct { type BYOKCredential struct {
APIKey string `json:"apiKey,omitempty"` APIKey string `json:"apiKey,omitzero"`
} }
// BYOKOptions represents Bring Your Own Key options for Vercel AI Gateway. // BYOKOptions represents Bring Your Own Key options for Vercel AI Gateway.
type BYOKOptions struct { type BYOKOptions struct {
Anthropic map[string][]BYOKCredential `json:"anthropic,omitempty"` Anthropic map[string][]BYOKCredential `json:"anthropic,omitzero"`
OpenAI map[string][]BYOKCredential `json:"openai,omitempty"` OpenAI map[string][]BYOKCredential `json:"openai,omitzero"`
Vertex map[string][]BYOKCredential `json:"vertex,omitempty"` Vertex map[string][]BYOKCredential `json:"vertex,omitzero"`
Bedrock map[string][]BYOKCredential `json:"bedrock,omitempty"` Bedrock map[string][]BYOKCredential `json:"bedrock,omitzero"`
} }
// ProviderOptions represents additional options for Vercel AI Gateway provider. // ProviderOptions represents additional options for Vercel AI Gateway provider.
type ProviderOptions struct { type ProviderOptions struct {
// Reasoning configuration for models that support extended thinking. // Reasoning configuration for models that support extended thinking.
Reasoning *ReasoningOptions `json:"reasoning,omitempty"` Reasoning *ReasoningOptions `json:"reasoning,omitzero"`
// ProviderOptions for gateway routing preferences. // ProviderOptions for gateway routing preferences.
ProviderOptions *GatewayProviderOptions `json:"providerOptions,omitempty"` ProviderOptions *GatewayProviderOptions `json:"providerOptions,omitzero"`
// BYOK for request-scoped provider credentials. // BYOK for request-scoped provider credentials.
BYOK *BYOKOptions `json:"byok,omitempty"` BYOK *BYOKOptions `json:"byok,omitzero"`
// User is a unique identifier representing your end-user. // User is a unique identifier representing your end-user.
User *string `json:"user,omitempty"` User *string `json:"user,omitzero"`
// LogitBias modifies the likelihood of specified tokens appearing in the completion. // LogitBias modifies the likelihood of specified tokens appearing in the completion.
LogitBias map[string]int64 `json:"logit_bias,omitempty"` LogitBias map[string]int64 `json:"logit_bias,omitzero"`
// LogProbs returns the log probabilities of the tokens. // LogProbs returns the log probabilities of the tokens.
LogProbs *bool `json:"logprobs,omitempty"` LogProbs *bool `json:"logprobs,omitzero"`
// TopLogProbs is the number of top log probabilities to return. // TopLogProbs is the number of top log probabilities to return.
TopLogProbs *int64 `json:"top_logprobs,omitempty"` TopLogProbs *int64 `json:"top_logprobs,omitzero"`
// ParallelToolCalls enables parallel function calling during tool use. // ParallelToolCalls enables parallel function calling during tool use.
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` ParallelToolCalls *bool `json:"parallel_tool_calls,omitzero"`
// ExtraBody for additional request body fields. // ExtraBody for additional request body fields.
ExtraBody map[string]any `json:"extra_body,omitempty"` ExtraBody map[string]any `json:"extra_body,omitzero"`
} }
// Options implements the ProviderOptionsData interface for ProviderOptions. // Options implements the ProviderOptionsData interface for ProviderOptions.
@ -128,7 +128,7 @@ func (o *ProviderOptions) UnmarshalJSON(data []byte) error {
// ProviderMetadata represents metadata from Vercel AI Gateway provider. // ProviderMetadata represents metadata from Vercel AI Gateway provider.
type ProviderMetadata struct { type ProviderMetadata struct {
Provider string `json:"provider,omitempty"` Provider string `json:"provider,omitzero"`
} }
// Options implements the ProviderOptionsData interface for ProviderMetadata. // Options implements the ProviderOptionsData interface for ProviderMetadata.
@ -153,20 +153,20 @@ func (m *ProviderMetadata) UnmarshalJSON(data []byte) error {
// ReasoningDetail represents a reasoning detail from Vercel AI Gateway. // ReasoningDetail represents a reasoning detail from Vercel AI Gateway.
type ReasoningDetail struct { type ReasoningDetail struct {
ID string `json:"id,omitempty"` ID string `json:"id,omitzero"`
Type string `json:"type,omitempty"` Type string `json:"type,omitzero"`
Text string `json:"text,omitempty"` Text string `json:"text,omitzero"`
Data string `json:"data,omitempty"` Data string `json:"data,omitzero"`
Format string `json:"format,omitempty"` Format string `json:"format,omitzero"`
Summary string `json:"summary,omitempty"` Summary string `json:"summary,omitzero"`
Signature string `json:"signature,omitempty"` Signature string `json:"signature,omitzero"`
Index int `json:"index"` Index int `json:"index"`
} }
// ReasoningData represents reasoning data from Vercel AI Gateway response. // ReasoningData represents reasoning data from Vercel AI Gateway response.
type ReasoningData struct { type ReasoningData struct {
Reasoning string `json:"reasoning,omitempty"` Reasoning string `json:"reasoning,omitzero"`
ReasoningDetails []ReasoningDetail `json:"reasoning_details,omitempty"` ReasoningDetails []ReasoningDetail `json:"reasoning_details,omitzero"`
} }
// ReasoningEffortOption creates a pointer to a ReasoningEffort value. // ReasoningEffortOption creates a pointer to a ReasoningEffort value.

View file

@ -1,7 +1,7 @@
package providertests package providertests
import ( import (
"encoding/json" jsonv2 "github.com/go-json-experiment/json"
"testing" "testing"
"charm.land/fantasy" "charm.land/fantasy"
@ -24,13 +24,13 @@ func TestProviderRegistry_Serialization_OpenAIOptions(t *testing.T) {
}, },
} }
data, err := json.Marshal(msg) data, err := jsonv2.Marshal(msg)
require.NoError(t, err) require.NoError(t, err)
var raw struct { var raw struct {
ProviderOptions map[string]map[string]any `json:"provider_options"` ProviderOptions map[string]map[string]any `json:"provider_options"`
} }
require.NoError(t, json.Unmarshal(data, &raw)) require.NoError(t, jsonv2.Unmarshal(data, &raw))
po, ok := raw.ProviderOptions[openai.Name] po, ok := raw.ProviderOptions[openai.Name]
require.True(t, ok) require.True(t, ok)
@ -41,7 +41,7 @@ func TestProviderRegistry_Serialization_OpenAIOptions(t *testing.T) {
require.Equal(t, "tester", inner["user"]) require.Equal(t, "tester", inner["user"])
var decoded fantasy.Message var decoded fantasy.Message
require.NoError(t, json.Unmarshal(data, &decoded)) require.NoError(t, jsonv2.Unmarshal(data, &decoded))
got, ok := decoded.ProviderOptions[openai.Name] got, ok := decoded.ProviderOptions[openai.Name]
require.True(t, ok) require.True(t, ok)
@ -66,14 +66,14 @@ func TestProviderRegistry_Serialization_OpenAIResponses(t *testing.T) {
}, },
} }
data, err := json.Marshal(msg) data, err := jsonv2.Marshal(msg)
require.NoError(t, err) require.NoError(t, err)
// JSON should include the typed wrapper with constant TypeResponsesProviderOptions // JSON should include the typed wrapper with constant TypeResponsesProviderOptions
var raw struct { var raw struct {
ProviderOptions map[string]map[string]any `json:"provider_options"` ProviderOptions map[string]map[string]any `json:"provider_options"`
} }
require.NoError(t, json.Unmarshal(data, &raw)) require.NoError(t, jsonv2.Unmarshal(data, &raw))
po := raw.ProviderOptions[openai.Name] po := raw.ProviderOptions[openai.Name]
require.Equal(t, openai.TypeResponsesProviderOptions, po["type"]) // no magic strings require.Equal(t, openai.TypeResponsesProviderOptions, po["type"]) // no magic strings
@ -84,7 +84,7 @@ func TestProviderRegistry_Serialization_OpenAIResponses(t *testing.T) {
// Unmarshal back and assert concrete type // Unmarshal back and assert concrete type
var decoded fantasy.Message var decoded fantasy.Message
require.NoError(t, json.Unmarshal(data, &decoded)) require.NoError(t, jsonv2.Unmarshal(data, &decoded))
got := decoded.ProviderOptions[openai.Name] got := decoded.ProviderOptions[openai.Name]
reqOpts, ok := got.(*openai.ResponsesProviderOptions) reqOpts, ok := got.(*openai.ResponsesProviderOptions)
require.True(t, ok) require.True(t, ok)
@ -109,7 +109,7 @@ func TestProviderRegistry_Serialization_OpenAIResponsesReasoningMetadata(t *test
}, },
} }
data, err := json.Marshal(resp) data, err := jsonv2.Marshal(resp)
require.NoError(t, err) require.NoError(t, err)
// Ensure the provider metadata is wrapped with type using constant // Ensure the provider metadata is wrapped with type using constant
@ -119,7 +119,7 @@ func TestProviderRegistry_Serialization_OpenAIResponsesReasoningMetadata(t *test
Data map[string]any `json:"data"` Data map[string]any `json:"data"`
} `json:"content"` } `json:"content"`
} }
require.NoError(t, json.Unmarshal(data, &raw)) require.NoError(t, jsonv2.Unmarshal(data, &raw))
require.Greater(t, len(raw.Content), 0) require.Greater(t, len(raw.Content), 0)
tc := raw.Content[0] tc := raw.Content[0]
pm, ok := tc.Data["provider_metadata"].(map[string]any) pm, ok := tc.Data["provider_metadata"].(map[string]any)
@ -133,7 +133,7 @@ func TestProviderRegistry_Serialization_OpenAIResponsesReasoningMetadata(t *test
// Unmarshal back // Unmarshal back
var decoded fantasy.Response var decoded fantasy.Response
require.NoError(t, json.Unmarshal(data, &decoded)) require.NoError(t, jsonv2.Unmarshal(data, &decoded))
pmDecoded := decoded.Content[0].(fantasy.TextContent).ProviderMetadata pmDecoded := decoded.Content[0].(fantasy.TextContent).ProviderMetadata
val, ok := pmDecoded[openai.Name] val, ok := pmDecoded[openai.Name]
require.True(t, ok) require.True(t, ok)
@ -157,11 +157,11 @@ func TestProviderRegistry_Serialization_AnthropicOptions(t *testing.T) {
}, },
} }
data, err := json.Marshal(msg) data, err := jsonv2.Marshal(msg)
require.NoError(t, err) require.NoError(t, err)
var decoded fantasy.Message var decoded fantasy.Message
require.NoError(t, json.Unmarshal(data, &decoded)) require.NoError(t, jsonv2.Unmarshal(data, &decoded))
got, ok := decoded.ProviderOptions[anthropic.Name] got, ok := decoded.ProviderOptions[anthropic.Name]
require.True(t, ok) require.True(t, ok)
@ -185,11 +185,11 @@ func TestProviderRegistry_Serialization_GoogleOptions(t *testing.T) {
}, },
} }
data, err := json.Marshal(msg) data, err := jsonv2.Marshal(msg)
require.NoError(t, err) require.NoError(t, err)
var decoded fantasy.Message var decoded fantasy.Message
require.NoError(t, json.Unmarshal(data, &decoded)) require.NoError(t, jsonv2.Unmarshal(data, &decoded))
got, ok := decoded.ProviderOptions[google.Name] got, ok := decoded.ProviderOptions[google.Name]
require.True(t, ok) require.True(t, ok)
@ -214,11 +214,11 @@ func TestProviderRegistry_Serialization_OpenRouterOptions(t *testing.T) {
}, },
} }
data, err := json.Marshal(msg) data, err := jsonv2.Marshal(msg)
require.NoError(t, err) require.NoError(t, err)
var decoded fantasy.Message var decoded fantasy.Message
require.NoError(t, json.Unmarshal(data, &decoded)) require.NoError(t, jsonv2.Unmarshal(data, &decoded))
got, ok := decoded.ProviderOptions[openrouter.Name] got, ok := decoded.ProviderOptions[openrouter.Name]
require.True(t, ok) require.True(t, ok)
@ -245,11 +245,11 @@ func TestProviderRegistry_Serialization_OpenAICompatOptions(t *testing.T) {
}, },
} }
data, err := json.Marshal(msg) data, err := jsonv2.Marshal(msg)
require.NoError(t, err) require.NoError(t, err)
var decoded fantasy.Message var decoded fantasy.Message
require.NoError(t, json.Unmarshal(data, &decoded)) require.NoError(t, jsonv2.Unmarshal(data, &decoded))
got, ok := decoded.ProviderOptions[openaicompat.Name] got, ok := decoded.ProviderOptions[openaicompat.Name]
require.True(t, ok) require.True(t, ok)
@ -277,11 +277,11 @@ func TestProviderRegistry_MultiProvider(t *testing.T) {
}, },
} }
data, err := json.Marshal(msg) data, err := jsonv2.Marshal(msg)
require.NoError(t, err) require.NoError(t, err)
var decoded fantasy.Message var decoded fantasy.Message
require.NoError(t, json.Unmarshal(data, &decoded)) require.NoError(t, jsonv2.Unmarshal(data, &decoded))
// Check OpenAI options // Check OpenAI options
openaiOpt, ok := decoded.ProviderOptions[openai.Name] openaiOpt, ok := decoded.ProviderOptions[openai.Name]
@ -312,7 +312,7 @@ func TestProviderRegistry_ErrorHandling(t *testing.T) {
}` }`
var msg fantasy.Message var msg fantasy.Message
err := json.Unmarshal([]byte(invalidJSON), &msg) err := jsonv2.Unmarshal([]byte(invalidJSON), &msg)
require.Error(t, err) require.Error(t, err)
require.Contains(t, err.Error(), "unknown provider data type") require.Contains(t, err.Error(), "unknown provider data type")
}) })
@ -327,7 +327,7 @@ func TestProviderRegistry_ErrorHandling(t *testing.T) {
}` }`
var msg fantasy.Message var msg fantasy.Message
err := json.Unmarshal([]byte(invalidJSON), &msg) err := jsonv2.Unmarshal([]byte(invalidJSON), &msg)
require.Error(t, err) require.Error(t, err)
}) })
} }
@ -364,11 +364,11 @@ func TestProviderRegistry_AllTypesRegistered(t *testing.T) {
} }
// Marshal and unmarshal // Marshal and unmarshal
data, err := json.Marshal(msg) data, err := jsonv2.Marshal(msg)
require.NoError(t, err) require.NoError(t, err)
var decoded fantasy.Message var decoded fantasy.Message
err = json.Unmarshal(data, &decoded) err = jsonv2.Unmarshal(data, &decoded)
require.NoError(t, err) require.NoError(t, err)
// Verify the provider options exist // Verify the provider options exist
@ -404,11 +404,11 @@ func TestProviderRegistry_AllTypesRegistered(t *testing.T) {
} }
// Marshal and unmarshal // Marshal and unmarshal
data, err := json.Marshal(resp) data, err := jsonv2.Marshal(resp)
require.NoError(t, err) require.NoError(t, err)
var decoded fantasy.Response var decoded fantasy.Response
err = json.Unmarshal(data, &decoded) err = jsonv2.Unmarshal(data, &decoded)
require.NoError(t, err) require.NoError(t, err)
// Verify the provider metadata exists // Verify the provider metadata exists

View file

@ -4,8 +4,8 @@ package schema
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"reflect" "reflect"
"slices" "slices"
"strings" "strings"
@ -259,7 +259,7 @@ func ParsePartialJSON(text string) (any, ParseState, error) {
} }
var result any var result any
if err := json.Unmarshal([]byte(text), &result); err == nil { if err := jsonv2.Unmarshal([]byte(text), &result); err == nil {
return result, ParseStateSuccessful, nil return result, ParseStateSuccessful, nil
} }
@ -268,7 +268,7 @@ func ParsePartialJSON(text string) (any, ParseState, error) {
return nil, ParseStateFailed, fmt.Errorf("json repair failed: %w", err) return nil, ParseStateFailed, fmt.Errorf("json repair failed: %w", err)
} }
if err := json.Unmarshal([]byte(repaired), &result); err != nil { if err := jsonv2.Unmarshal([]byte(repaired), &result); err != nil {
return nil, ParseStateFailed, fmt.Errorf("failed to parse repaired json: %w", err) return nil, ParseStateFailed, fmt.Errorf("failed to parse repaired json: %w", err)
} }
@ -317,7 +317,7 @@ func ValidateAgainstSchema(obj any, schema Schema) error {
// This is a convenience wrapper for use sites that hold the schema as a map rather // This is a convenience wrapper for use sites that hold the schema as a map rather
// than the typed Schema struct. // than the typed Schema struct.
func ValidateAgainstSchemaMap(obj any, schemaMap map[string]any) error { func ValidateAgainstSchemaMap(obj any, schemaMap map[string]any) error {
schemaBytes, err := json.Marshal(schemaMap) schemaBytes, err := jsonv2.Marshal(schemaMap)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal schema map: %w", err) return fmt.Errorf("failed to marshal schema map: %w", err)
} }
@ -341,7 +341,7 @@ func ValidateAgainstSchemaMap(obj any, schemaMap map[string]any) error {
} }
func validateAgainstSchema(obj any, schema Schema) error { func validateAgainstSchema(obj any, schema Schema) error {
jsonSchemaBytes, err := json.Marshal(schema) jsonSchemaBytes, err := jsonv2.Marshal(schema)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal schema: %w", err) return fmt.Errorf("failed to marshal schema: %w", err)
} }

View file

@ -2,8 +2,8 @@ package fantasy
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"reflect" "reflect"
"charm.land/fantasy/schema" "charm.land/fantasy/schema"
@ -78,7 +78,7 @@ func NewMediaResponse(data []byte, mediaType string) ToolResponse {
// WithResponseMetadata adds metadata to a response. // WithResponseMetadata adds metadata to a response.
func WithResponseMetadata(response ToolResponse, metadata any) ToolResponse { func WithResponseMetadata(response ToolResponse, metadata any) ToolResponse {
if metadata != nil { if metadata != nil {
metadataBytes, err := json.Marshal(metadata) metadataBytes, err := jsonv2.Marshal(metadata)
if err != nil { if err != nil {
return response return response
} }
@ -167,7 +167,7 @@ func (w *funcToolWrapper[TInput]) Info() ToolInfo {
func (w *funcToolWrapper[TInput]) Run(ctx context.Context, params ToolCall) (ToolResponse, error) { func (w *funcToolWrapper[TInput]) Run(ctx context.Context, params ToolCall) (ToolResponse, error) {
var input TInput var input TInput
if err := json.Unmarshal([]byte(params.Input), &input); err != nil { if err := jsonv2.Unmarshal([]byte(params.Input), &input); err != nil {
return NewTextErrorResponse(fmt.Sprintf("invalid parameters: %s", err)), nil return NewTextErrorResponse(fmt.Sprintf("invalid parameters: %s", err)), nil
} }

View file

@ -1,9 +1,9 @@
package fantasy package fantasy
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"strings" "strings"
) )
@ -91,7 +91,7 @@ func extractToolDependencies(input string) ([]string, error) {
} }
var v any var v any
if err := json.Unmarshal([]byte(input), &v); err != nil { if err := jsonv2.Unmarshal([]byte(input), &v); err != nil {
// If the tool input isn't valid JSON, treat it as having no dependencies. // If the tool input isn't valid JSON, treat it as having no dependencies.
return nil, nil return nil, nil
} }

View file

@ -2,9 +2,9 @@ package fantasy
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@ -233,7 +233,7 @@ func resolveToolRefsInInput(input string, results map[string]ToolResultContent)
} }
var v any var v any
if err := json.Unmarshal([]byte(input), &v); err != nil { if err := jsonv2.Unmarshal([]byte(input), &v); err != nil {
// Not JSON; nothing to resolve. // Not JSON; nothing to resolve.
return input, nil return input, nil
} }
@ -242,7 +242,7 @@ func resolveToolRefsInInput(input string, results map[string]ToolResultContent)
if err != nil { if err != nil {
return "", err return "", err
} }
b, err := json.Marshal(updated) b, err := jsonv2.Marshal(updated)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -328,7 +328,7 @@ func resolveToolRefValue(ref string, results map[string]ToolResultContent) (any,
// Path resolution: interpret base as JSON and walk. // Path resolution: interpret base as JSON and walk.
var cur any var cur any
if err := json.Unmarshal([]byte(base), &cur); err != nil { if err := jsonv2.Unmarshal([]byte(base), &cur); err != nil {
return nil, fmt.Errorf("tool ref %q path requires JSON output, got non-JSON", ref) return nil, fmt.Errorf("tool ref %q path requires JSON output, got non-JSON", ref)
} }

View file

@ -5,7 +5,7 @@ package conversations
import ( import (
"context" "context"
"encoding/json" jsonv2 "github.com/go-json-experiment/json"
"strings" "strings"
"github.com/sipeed/picoclaw/pkg/ids" "github.com/sipeed/picoclaw/pkg/ids"
@ -100,7 +100,7 @@ func (s *Store) EditMessage(ctx context.Context, p EditMessageParams) (sqlc.Agen
return sqlc.AgentMessage{}, err return sqlc.AgentMessage{}, err
} }
metaJSON, _ := json.Marshal(p.Metadata) metaJSON, _ := jsonv2.Marshal(p.Metadata)
// Best-effort revision record — a failure here does not abort the edit. // Best-effort revision record — a failure here does not abort the edit.
_, _ = s.q.AddAgentMessageRevision(ctx, sqlc.AddAgentMessageRevisionParams{ _, _ = s.q.AddAgentMessageRevision(ctx, sqlc.AddAgentMessageRevisionParams{
@ -174,7 +174,7 @@ func (s *Store) ForkFromCheckpoint(ctx context.Context, p ForkFromCheckpointPara
Messages []msgSnapshot `json:"messages"` Messages []msgSnapshot `json:"messages"`
} }
var snap snapshot var snap snapshot
_ = json.Unmarshal(runState.SnapshotJson, &snap) _ = jsonv2.Unmarshal([]byte(runState.SnapshotJson), &snap)
conv, err := s.q.CreateAgentConversation(ctx, sqlc.CreateAgentConversationParams{ conv, err := s.q.CreateAgentConversation(ctx, sqlc.CreateAgentConversationParams{
ID: ids.New(), ID: ids.New(),
@ -188,7 +188,7 @@ func (s *Store) ForkFromCheckpoint(ctx context.Context, p ForkFromCheckpointPara
"checkpoint_name": cpName, "checkpoint_name": cpName,
"run_state_id": cp.RunStateID.String(), "run_state_id": cp.RunStateID.String(),
} }
forkMetaJSON, _ := json.Marshal(forkMeta) forkMetaJSON, _ := jsonv2.Marshal(forkMeta)
_, _ = s.q.CreateAgentConversationFork(ctx, sqlc.CreateAgentConversationForkParams{ _, _ = s.q.CreateAgentConversationFork(ctx, sqlc.CreateAgentConversationForkParams{
ID: ids.New(), ID: ids.New(),
@ -209,7 +209,7 @@ func (s *Store) ForkFromCheckpoint(ctx context.Context, p ForkFromCheckpointPara
"checkpoint_name": cpName, "checkpoint_name": cpName,
"run_state_id": cp.RunStateID.String(), "run_state_id": cp.RunStateID.String(),
} }
seedMetaJSON, _ := json.Marshal(seedMeta) seedMetaJSON, _ := jsonv2.Marshal(seedMeta)
for _, m := range msgs { for _, m := range msgs {
if m.Role != "user" && m.Role != "assistant" { if m.Role != "user" && m.Role != "assistant" {
@ -277,7 +277,7 @@ func (s *Store) MergeAsLinkedContext(ctx context.Context, p MergeAsLinkedContext
} }
meta := map[string]any{"source": "merge_as_linked_context"} meta := map[string]any{"source": "merge_as_linked_context"}
metaJSON, _ := json.Marshal(meta) metaJSON, _ := jsonv2.Marshal(meta)
_, _ = s.q.CreateAgentConversationLink(ctx, sqlc.CreateAgentConversationLinkParams{ _, _ = s.q.CreateAgentConversationLink(ctx, sqlc.CreateAgentConversationLinkParams{
ID: ids.New(), ID: ids.New(),
@ -321,7 +321,7 @@ type AncestryParams struct {
// AncestryResult is the structured result of an Ancestry query. // AncestryResult is the structured result of an Ancestry query.
type AncestryResult struct { type AncestryResult struct {
Conversation sqlc.AgentConversation `json:"conversation"` Conversation sqlc.AgentConversation `json:"conversation"`
ForkParent *sqlc.AgentConversationFork `json:"fork_parent,omitempty"` ForkParent *sqlc.AgentConversationFork `json:"fork_parent,omitzero"`
ForkChildren []sqlc.AgentConversationFork `json:"fork_children"` ForkChildren []sqlc.AgentConversationFork `json:"fork_children"`
Links []sqlc.AgentConversationLink `json:"links"` Links []sqlc.AgentConversationLink `json:"links"`
} }
@ -434,7 +434,7 @@ type GraphParams struct {
// GraphNode is a single conversation node in the fork/link graph. // GraphNode is a single conversation node in the fork/link graph.
type GraphNode struct { type GraphNode struct {
ID string `json:"id"` ID string `json:"id"`
Title *string `json:"title,omitempty"` Title *string `json:"title,omitzero"`
} }
// GraphEdge is a directed edge between two conversation nodes. // GraphEdge is a directed edge between two conversation nodes.
@ -444,9 +444,9 @@ type GraphEdge struct {
From string `json:"from"` From string `json:"from"`
To string `json:"to"` To string `json:"to"`
// CheckpointID is set on fork edges. // CheckpointID is set on fork edges.
CheckpointID *string `json:"checkpoint_id,omitempty"` CheckpointID *string `json:"checkpoint_id,omitzero"`
// Kind is set on link edges (e.g. "merge"). // Kind is set on link edges (e.g. "merge").
Kind *string `json:"kind,omitempty"` Kind *string `json:"kind,omitzero"`
} }
// GraphResult is the full fork/link graph rooted at a conversation. // GraphResult is the full fork/link graph rooted at a conversation.

View file

@ -7,7 +7,7 @@ package agent
import ( import (
"context" "context"
"encoding/json" jsonv2 "github.com/go-json-experiment/json"
memstore "github.com/sipeed/picoclaw/pkg/memory/store" memstore "github.com/sipeed/picoclaw/pkg/memory/store"
"github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/tools"
@ -84,7 +84,7 @@ func (t *MemGPTTool) Parameters() map[string]interface{} {
func (t *MemGPTTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult { func (t *MemGPTTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult {
// Marshal the args back to JSON for the inner MemoryTool.Execute() // Marshal the args back to JSON for the inner MemoryTool.Execute()
input, err := json.Marshal(args) input, err := jsonv2.Marshal(args)
if err != nil { if err != nil {
return tools.ErrorResult("invalid arguments: " + err.Error()) return tools.ErrorResult("invalid arguments: " + err.Error())
} }

View file

@ -5,7 +5,7 @@ package mentions
import ( import (
"context" "context"
"encoding/json" jsonv2 "github.com/go-json-experiment/json"
"strings" "strings"
"github.com/sipeed/picoclaw/pkg/ids" "github.com/sipeed/picoclaw/pkg/ids"
@ -76,7 +76,7 @@ func (s *Store) Add(ctx context.Context, p AddParams) (sqlc.AgentMention, error)
return sqlc.AgentMention{}, pcerrors.Wrapf(pcerrors.CodeInvalidArgument, err, "parse target_id %q", targetIDStr) return sqlc.AgentMention{}, pcerrors.Wrapf(pcerrors.CodeInvalidArgument, err, "parse target_id %q", targetIDStr)
} }
metaJSON, _ := json.Marshal(p.Metadata) metaJSON, _ := jsonv2.Marshal(p.Metadata)
return s.q.AddAgentMention(ctx, sqlc.AddAgentMentionParams{ return s.q.AddAgentMention(ctx, sqlc.AddAgentMentionParams{
ID: ids.New(), ID: ids.New(),

View file

@ -2,8 +2,8 @@ package agent
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"strconv" "strconv"
"strings" "strings"
@ -91,7 +91,7 @@ func (r OffloadingToolRuntime) Execute(ctx context.Context, tools []fantasy.Agen
fullKey := toolResultFullKey(r.ConversationID, r.RunID, stepIndex, tc.ToolCallID) fullKey := toolResultFullKey(r.ConversationID, r.RunID, stepIndex, tc.ToolCallID)
payload, payloadType, payloadText := toolResultPayload(res) payload, payloadType, payloadText := toolResultPayload(res)
b, marshalErr := json.Marshal(payload) b, marshalErr := jsonv2.Marshal(payload)
if marshalErr != nil { if marshalErr != nil {
b = []byte(`{"error":"failed to marshal tool result payload"}`) b = []byte(`{"error":"failed to marshal tool result payload"}`)
payloadType = "error" payloadType = "error"
@ -134,7 +134,7 @@ func (r OffloadingToolRuntime) Execute(ctx context.Context, tools []fantasy.Agen
"result_type": payloadType, "result_type": payloadType,
"step_index": stepIndex, "step_index": stepIndex,
} }
metaJSON, _ := json.Marshal(meta) metaJSON, _ := jsonv2.Marshal(meta)
var previewPtr *string var previewPtr *string
if strings.TrimSpace(preview) != "" { if strings.TrimSpace(preview) != "" {

View file

@ -6,6 +6,8 @@ import (
"strings" "strings"
"time" "time"
jsonv2 "github.com/go-json-experiment/json"
"charm.land/fantasy" "charm.land/fantasy"
"github.com/sipeed/picoclaw/pkg/ids" "github.com/sipeed/picoclaw/pkg/ids"
"github.com/sipeed/picoclaw/pkg/memory/sqlc" "github.com/sipeed/picoclaw/pkg/memory/sqlc"
@ -50,8 +52,8 @@ func (s *StateStore) UpdateRunStatus(ctx context.Context, runID ids.UUID, status
metaJSON := json.RawMessage(`{}`) metaJSON := json.RawMessage(`{}`)
if meta != nil { if meta != nil {
if b, err := json.Marshal(meta); err == nil { if b, err := jsonv2.Marshal(meta); err == nil {
metaJSON = b metaJSON = json.RawMessage(b)
} }
} }
@ -75,8 +77,8 @@ func (s *StateStore) AddRunState(ctx context.Context, runID ids.UUID, stepIndex
snapJSON := json.RawMessage(`{}`) snapJSON := json.RawMessage(`{}`)
if snapshot != nil { if snapshot != nil {
if b, err := json.Marshal(snapshot); err == nil { if b, err := jsonv2.Marshal(snapshot); err == nil {
snapJSON = b snapJSON = json.RawMessage(b)
} }
} }
@ -99,8 +101,8 @@ func (s *StateStore) AddTransition(ctx context.Context, runID ids.UUID, t fantas
metaJSON := json.RawMessage(`{}`) metaJSON := json.RawMessage(`{}`)
if t.Meta != nil { if t.Meta != nil {
if b, err := json.Marshal(t.Meta); err == nil { if b, err := jsonv2.Marshal(t.Meta); err == nil {
metaJSON = b metaJSON = json.RawMessage(b)
} }
} }
@ -152,8 +154,8 @@ func (s *CheckpointStore) CreateCheckpoint(ctx context.Context, conversationID i
metaJSON := json.RawMessage(`{}`) metaJSON := json.RawMessage(`{}`)
if meta != nil { if meta != nil {
if b, err := json.Marshal(meta); err == nil { if b, err := jsonv2.Marshal(meta); err == nil {
metaJSON = b metaJSON = json.RawMessage(b)
} }
} }

View file

@ -6,7 +6,7 @@ package threads
import ( import (
"context" "context"
"encoding/json" jsonv2 "github.com/go-json-experiment/json"
"strings" "strings"
"github.com/sipeed/picoclaw/pkg/ids" "github.com/sipeed/picoclaw/pkg/ids"
@ -45,7 +45,7 @@ func (s *Store) Create(ctx context.Context, p CreateParams) (sqlc.AgentThread, e
return sqlc.AgentThread{}, pcerrors.Wrapf(pcerrors.CodeInvalidArgument, err, "parse conversation_id %q", convIDStr) return sqlc.AgentThread{}, pcerrors.Wrapf(pcerrors.CodeInvalidArgument, err, "parse conversation_id %q", convIDStr)
} }
metaJSON, _ := json.Marshal(p.Metadata) metaJSON, _ := jsonv2.Marshal(p.Metadata)
return s.q.CreateAgentThread(ctx, sqlc.CreateAgentThreadParams{ return s.q.CreateAgentThread(ctx, sqlc.CreateAgentThreadParams{
ID: ids.New(), ID: ids.New(),
@ -108,7 +108,7 @@ func (s *Store) AddMessage(ctx context.Context, p AddMessageParams) (sqlc.AgentT
return sqlc.AgentThreadMessage{}, pcerrors.New(pcerrors.CodeInvalidArgument, "content is empty") return sqlc.AgentThreadMessage{}, pcerrors.New(pcerrors.CodeInvalidArgument, "content is empty")
} }
metaJSON, _ := json.Marshal(p.Metadata) metaJSON, _ := jsonv2.Marshal(p.Metadata)
return s.q.AddAgentThreadMessage(ctx, sqlc.AddAgentThreadMessageParams{ return s.q.AddAgentThreadMessage(ctx, sqlc.AddAgentThreadMessageParams{
ID: ids.New(), ID: ids.New(),

View file

@ -2,10 +2,12 @@ package agent
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"strings" "strings"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"charm.land/fantasy" "charm.land/fantasy"
"github.com/sipeed/picoclaw/pkg/ids" "github.com/sipeed/picoclaw/pkg/ids"
"github.com/sipeed/picoclaw/pkg/memory/sqlc" "github.com/sipeed/picoclaw/pkg/memory/sqlc"
@ -13,23 +15,23 @@ import (
) )
type ToolResultSearchView struct { type ToolResultSearchView struct {
StartLine int `json:"start_line,omitempty" description:"Optional. 1-indexed start line (inclusive)."` StartLine int `json:"start_line,omitzero" description:"Optional. 1-indexed start line (inclusive)."`
EndLine int `json:"end_line,omitempty" description:"Optional. 1-indexed end line (inclusive)."` EndLine int `json:"end_line,omitzero" description:"Optional. 1-indexed end line (inclusive)."`
MaxLines int `json:"max_lines,omitempty" description:"Optional. Default 30, max 200."` MaxLines int `json:"max_lines,omitzero" description:"Optional. Default 30, max 200."`
StartChunk int `json:"start_chunk,omitempty" description:"Optional. 0-indexed start chunk (inclusive)."` StartChunk int `json:"start_chunk,omitzero" description:"Optional. 0-indexed start chunk (inclusive)."`
EndChunk int `json:"end_chunk,omitempty" description:"Optional. 0-indexed end chunk (inclusive)."` EndChunk int `json:"end_chunk,omitzero" description:"Optional. 0-indexed end chunk (inclusive)."`
MaxChunks int `json:"max_chunks,omitempty" description:"Optional. Default 3, max 20."` MaxChunks int `json:"max_chunks,omitzero" description:"Optional. Default 3, max 20."`
} }
type ToolResultSearchInput struct { type ToolResultSearchInput struct {
ConversationID string `json:"conversation_id,omitempty" description:"Optional. Agent conversation UUID."` ConversationID string `json:"conversation_id,omitzero" description:"Optional. Agent conversation UUID."`
RunID string `json:"run_id,omitempty" description:"Optional. Agent run UUID."` RunID string `json:"run_id,omitzero" description:"Optional. Agent run UUID."`
ToolCallID string `json:"tool_call_id,omitempty" description:"Optional. Tool call id to fetch (requires run_id)."` ToolCallID string `json:"tool_call_id,omitzero" description:"Optional. Tool call id to fetch (requires run_id)."`
ToolName string `json:"tool_name,omitempty" description:"Optional. Filter by tool name."` ToolName string `json:"tool_name,omitzero" description:"Optional. Filter by tool name."`
Query string `json:"query,omitempty" description:"Optional. Case-insensitive substring match on tool_name/tool_call_id/summary."` Query string `json:"query,omitzero" description:"Optional. Case-insensitive substring match on tool_name/tool_call_id/summary."`
Limit int `json:"limit,omitempty" description:"Optional. Default 5, max 50."` Limit int `json:"limit,omitzero" description:"Optional. Default 5, max 50."`
View *ToolResultSearchView `json:"view,omitempty" description:"Optional. File view range for each result."` View *ToolResultSearchView `json:"view,omitzero" description:"Optional. File view range for each result."`
} }
// NewToolResultSearchTool creates the tool_result_search agent tool for // NewToolResultSearchTool creates the tool_result_search agent tool for
@ -87,17 +89,17 @@ func NewToolResultSearchTool(q *sqlc.Queries, kv KVDelegate) fantasy.AgentTool {
startChunk, endChunk := normalizeChunkView(view) startChunk, endChunk := normalizeChunkView(view)
type item struct { type item struct {
ID string `json:"id"` ID string `json:"id"`
RunID string `json:"run_id"` RunID string `json:"run_id"`
StepIndex int64 `json:"step_index"` StepIndex int64 `json:"step_index"`
ToolCallID string `json:"tool_call_id"` ToolCallID string `json:"tool_call_id"`
ToolName string `json:"tool_name"` ToolName string `json:"tool_name"`
Preview *string `json:"preview,omitempty"` Preview *string `json:"preview,omitzero"`
FullKey string `json:"full_key"` FullKey string `json:"full_key"`
ChunkCount int64 `json:"chunk_count"` ChunkCount int64 `json:"chunk_count"`
View string `json:"view"` View string `json:"view"`
ViewRange map[string]int `json:"view_range"` ViewRange map[string]int `json:"view_range"`
Metadata json.RawMessage `json:"metadata_json"` Metadata jsontext.Value `json:"metadata_json"` // sqlc gives json.RawMessage; both are []byte
} }
out := struct { out := struct {
@ -131,11 +133,11 @@ func NewToolResultSearchTool(q *sqlc.Queries, kv KVDelegate) fantasy.AgentTool {
ChunkCount: r.ChunkCount, ChunkCount: r.ChunkCount,
View: sel, View: sel,
ViewRange: viewRange, ViewRange: viewRange,
Metadata: r.MetadataJson, Metadata: jsontext.Value(r.MetadataJson),
}) })
} }
b, _ := json.Marshal(out) b, _ := jsonv2.Marshal(out)
return fantasy.NewTextResponse(string(b)), nil return fantasy.NewTextResponse(string(b)), nil
}, },
) )

View file

@ -2,7 +2,7 @@ package agent_test
import ( import (
"context" "context"
"encoding/json" jsonv2 "github.com/go-json-experiment/json"
"strings" "strings"
"testing" "testing"
@ -58,13 +58,13 @@ func invokeSearch(t *testing.T, tool fantasy.AgentTool, input agent.ToolResultSe
require.False(t, resp.IsError, "search tool must not error: %s", resp.Content) require.False(t, resp.IsError, "search tool must not error: %s", resp.Content)
var out map[string]any var out map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Content), &out)) require.NoError(t, jsonv2.Unmarshal([]byte(resp.Content), &out))
return out return out
} }
func marshalInput(t *testing.T, v any) string { func marshalInput(t *testing.T, v any) string {
t.Helper() t.Helper()
b, err := json.Marshal(v) b, err := jsonv2.Marshal(v)
require.NoError(t, err) require.NoError(t, err)
return string(b) return string(b)
} }

View file

@ -5,7 +5,6 @@ import (
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net" "net"
@ -16,6 +15,9 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
) )
type OAuthProviderConfig struct { type OAuthProviderConfig struct {
@ -128,12 +130,12 @@ type deviceCodeResponse struct {
func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) { func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) {
var raw struct { var raw struct {
DeviceAuthID string `json:"device_auth_id"` DeviceAuthID string `json:"device_auth_id"`
UserCode string `json:"user_code"` UserCode string `json:"user_code"`
Interval json.RawMessage `json:"interval"` Interval jsontext.Value `json:"interval"`
} }
if err := json.Unmarshal(body, &raw); err != nil { if err := jsonv2.Unmarshal(body, &raw); err != nil {
return deviceCodeResponse{}, err return deviceCodeResponse{}, err
} }
@ -149,18 +151,18 @@ func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) {
}, nil }, nil
} }
func parseFlexibleInt(raw json.RawMessage) (int, error) { func parseFlexibleInt(raw jsontext.Value) (int, error) {
if len(raw) == 0 || string(raw) == "null" { if len(raw) == 0 || string(raw) == "null" {
return 0, nil return 0, nil
} }
var interval int var interval int
if err := json.Unmarshal(raw, &interval); err == nil { if err := jsonv2.Unmarshal(raw, &interval); err == nil {
return interval, nil return interval, nil
} }
var intervalStr string var intervalStr string
if err := json.Unmarshal(raw, &intervalStr); err == nil { if err := jsonv2.Unmarshal(raw, &intervalStr); err == nil {
intervalStr = strings.TrimSpace(intervalStr) intervalStr = strings.TrimSpace(intervalStr)
if intervalStr == "" { if intervalStr == "" {
return 0, nil return 0, nil
@ -172,7 +174,7 @@ func parseFlexibleInt(raw json.RawMessage) (int, error) {
} }
func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) { func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) {
reqBody, _ := json.Marshal(map[string]string{ reqBody, _ := jsonv2.Marshal(map[string]string{
"client_id": cfg.ClientID, "client_id": cfg.ClientID,
}) })
@ -224,7 +226,7 @@ func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) {
} }
func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*AuthCredential, error) { func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*AuthCredential, error) {
reqBody, _ := json.Marshal(map[string]string{ reqBody, _ := jsonv2.Marshal(map[string]string{
"device_auth_id": deviceAuthID, "device_auth_id": deviceAuthID,
"user_code": userCode, "user_code": userCode,
}) })
@ -250,7 +252,7 @@ func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*Au
CodeChallenge string `json:"code_challenge"` CodeChallenge string `json:"code_challenge"`
CodeVerifier string `json:"code_verifier"` CodeVerifier string `json:"code_verifier"`
} }
if err := json.Unmarshal(body, &tokenResp); err != nil { if err := jsonv2.Unmarshal(body, &tokenResp); err != nil {
return nil, err return nil, err
} }
@ -349,7 +351,7 @@ func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {
ExpiresIn int `json:"expires_in"` ExpiresIn int `json:"expires_in"`
IDToken string `json:"id_token"` IDToken string `json:"id_token"`
} }
if err := json.Unmarshal(body, &tokenResp); err != nil { if err := jsonv2.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("parsing token response: %w", err) return nil, fmt.Errorf("parsing token response: %w", err)
} }
@ -435,7 +437,7 @@ func parseJWTClaims(token string) (map[string]interface{}, error) {
} }
var claims map[string]interface{} var claims map[string]interface{}
if err := json.Unmarshal(decoded, &claims); err != nil { if err := jsonv2.Unmarshal(decoded, &claims); err != nil {
return nil, err return nil, err
} }

View file

@ -2,19 +2,20 @@ package auth
import ( import (
"encoding/base64" "encoding/base64"
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"strings" "strings"
"testing" "testing"
jsonv2 "github.com/go-json-experiment/json"
) )
func makeJWTForClaims(t *testing.T, claims map[string]interface{}) string { func makeJWTForClaims(t *testing.T, claims map[string]interface{}) string {
t.Helper() t.Helper()
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
payloadJSON, err := json.Marshal(claims) payloadJSON, err := jsonv2.Marshal(claims)
if err != nil { if err != nil {
t.Fatalf("marshal claims: %v", err) t.Fatalf("marshal claims: %v", err)
} }
@ -95,7 +96,7 @@ func TestParseTokenResponse(t *testing.T) {
"expires_in": 3600, "expires_in": 3600,
"id_token": "test-id-token", "id_token": "test-id-token",
} }
body, _ := json.Marshal(resp) body, _ := jsonv2.Marshal(resp)
cred, err := parseTokenResponse(body, "openai") cred, err := parseTokenResponse(body, "openai")
if err != nil { if err != nil {
@ -127,7 +128,7 @@ func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) {
"expires_in": 3600, "expires_in": 3600,
"id_token": idToken, "id_token": idToken,
} }
body, _ := json.Marshal(resp) body, _ := jsonv2.Marshal(resp)
cred, err := parseTokenResponse(body, "openai") cred, err := parseTokenResponse(body, "openai")
if err != nil { if err != nil {
@ -166,7 +167,7 @@ func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) {
"expires_in": 3600, "expires_in": 3600,
"id_token": idToken, "id_token": idToken,
} }
body, _ := json.Marshal(resp) body, _ := jsonv2.Marshal(resp)
cred, err := parseTokenResponse(body, "openai") cred, err := parseTokenResponse(body, "openai")
if err != nil { if err != nil {
@ -206,7 +207,7 @@ func TestExchangeCodeForTokens(t *testing.T) {
"refresh_token": "mock-refresh-token", "refresh_token": "mock-refresh-token",
"expires_in": 3600, "expires_in": 3600,
} }
json.NewEncoder(w).Encode(resp) jsonv2.MarshalWrite(w, resp)
})) }))
defer server.Close() defer server.Close()
@ -245,7 +246,7 @@ func TestRefreshAccessToken(t *testing.T) {
"refresh_token": "refreshed-refresh-token", "refresh_token": "refreshed-refresh-token",
"expires_in": 3600, "expires_in": 3600,
} }
json.NewEncoder(w).Encode(resp) jsonv2.MarshalWrite(w, resp)
})) }))
defer server.Close() defer server.Close()
@ -294,7 +295,7 @@ func TestRefreshAccessTokenPreservesRefreshAndAccountID(t *testing.T) {
"access_token": "new-access-token-only", "access_token": "new-access-token-only",
"expires_in": 3600, "expires_in": 3600,
} }
json.NewEncoder(w).Encode(resp) jsonv2.MarshalWrite(w, resp)
})) }))
defer server.Close() defer server.Close()

View file

@ -1,17 +1,19 @@
package auth package auth
import ( import (
"encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
) )
type AuthCredential struct { type AuthCredential struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"` RefreshToken string `json:"refresh_token,omitzero"`
AccountID string `json:"account_id,omitempty"` AccountID string `json:"account_id,omitzero"`
ExpiresAt time.Time `json:"expires_at,omitempty"` ExpiresAt time.Time `json:"expires_at,omitzero"`
Provider string `json:"provider"` Provider string `json:"provider"`
AuthMethod string `json:"auth_method"` AuthMethod string `json:"auth_method"`
} }
@ -50,7 +52,7 @@ func LoadStore() (*AuthStore, error) {
} }
var store AuthStore var store AuthStore
if err := json.Unmarshal(data, &store); err != nil { if err := jsonv2.Unmarshal(data, &store); err != nil {
return nil, err return nil, err
} }
if store.Credentials == nil { if store.Credentials == nil {
@ -66,7 +68,7 @@ func SaveStore(store *AuthStore) error {
return err return err
} }
data, err := json.MarshalIndent(store, "", " ") data, err := jsonv2.Marshal(store, jsontext.WithIndent(" "))
if err != nil { if err != nil {
return err return err
} }

View file

@ -4,11 +4,12 @@ package channels
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"sync" "sync"
"time" "time"
jsonv2 "github.com/go-json-experiment/json"
lark "github.com/larksuite/oapi-sdk-go/v3" lark "github.com/larksuite/oapi-sdk-go/v3"
larkdispatcher "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher" larkdispatcher "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
@ -97,7 +98,7 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return fmt.Errorf("chat ID is empty") return fmt.Errorf("chat ID is empty")
} }
payload, err := json.Marshal(map[string]string{"text": msg.Content}) payload, err := jsonv2.Marshal(map[string]string{"text": msg.Content})
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal feishu content: %w", err) return fmt.Errorf("failed to marshal feishu content: %w", err)
} }
@ -202,7 +203,7 @@ func extractFeishuMessageContent(message *larkim.EventMessage) string {
var textPayload struct { var textPayload struct {
Text string `json:"text"` Text string `json:"text"`
} }
if err := json.Unmarshal([]byte(*message.Content), &textPayload); err == nil { if err := jsonv2.Unmarshal([]byte(*message.Content), &textPayload); err == nil {
return textPayload.Text return textPayload.Text
} }
} }

View file

@ -6,7 +6,6 @@ import (
"crypto/hmac" "crypto/hmac"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -15,6 +14,9 @@ import (
"sync" "sync"
"time" "time"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
@ -140,7 +142,7 @@ func (c *LINEChannel) fetchBotInfo() error {
BasicID string `json:"basicId"` BasicID string `json:"basicId"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
} }
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { if err := jsonv2.UnmarshalRead(resp.Body, &info); err != nil {
return err return err
} }
@ -199,7 +201,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
var payload struct { var payload struct {
Events []lineEvent `json:"events"` Events []lineEvent `json:"events"`
} }
if err := json.Unmarshal(body, &payload); err != nil { if err := jsonv2.Unmarshal(body, &payload); err != nil {
logger.ErrorCF("line", "Failed to parse webhook payload", map[string]interface{}{ logger.ErrorCF("line", "Failed to parse webhook payload", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
}) })
@ -230,11 +232,11 @@ func (c *LINEChannel) verifySignature(body []byte, signature string) bool {
// LINE webhook event types // LINE webhook event types
type lineEvent struct { type lineEvent struct {
Type string `json:"type"` Type string `json:"type"`
ReplyToken string `json:"replyToken"` ReplyToken string `json:"replyToken"`
Source lineSource `json:"source"` Source lineSource `json:"source"`
Message json.RawMessage `json:"message"` Message jsontext.Value `json:"message"`
Timestamp int64 `json:"timestamp"` Timestamp int64 `json:"timestamp"`
} }
type lineSource struct { type lineSource struct {
@ -277,7 +279,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
isGroup := event.Source.Type == "group" || event.Source.Type == "room" isGroup := event.Source.Type == "group" || event.Source.Type == "room"
var msg lineMessage var msg lineMessage
if err := json.Unmarshal(event.Message, &msg); err != nil { if err := jsonv2.Unmarshal(event.Message, &msg); err != nil {
logger.ErrorCF("line", "Failed to parse message", map[string]interface{}{ logger.ErrorCF("line", "Failed to parse message", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
}) })
@ -558,7 +560,7 @@ func (c *LINEChannel) sendLoading(chatID string) {
// callAPI makes an authenticated POST request to the LINE API. // callAPI makes an authenticated POST request to the LINE API.
func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload interface{}) error { func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload interface{}) error {
body, err := json.Marshal(payload) body, err := jsonv2.Marshal(payload)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err) return fmt.Errorf("failed to marshal payload: %w", err)
} }

View file

@ -2,11 +2,12 @@ package channels
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"net" "net"
"sync" "sync"
jsonv2 "github.com/go-json-experiment/json"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
@ -102,15 +103,13 @@ func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) {
logger.DebugC("maixcam", "Connection closed") logger.DebugC("maixcam", "Connection closed")
}() }()
decoder := json.NewDecoder(conn)
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
default: default:
var msg MaixCamMessage var msg MaixCamMessage
if err := decoder.Decode(&msg); err != nil { if err := jsonv2.UnmarshalRead(conn, &msg); err != nil {
if err.Error() != "EOF" { if err.Error() != "EOF" {
logger.ErrorCF("maixcam", "Failed to decode message", map[string]interface{}{ logger.ErrorCF("maixcam", "Failed to decode message", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
@ -221,7 +220,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
"chat_id": msg.ChatID, "chat_id": msg.ChatID,
} }
data, err := json.Marshal(response) data, err := jsonv2.Marshal(response)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal response: %w", err) return fmt.Errorf("failed to marshal response: %w", err)
} }

View file

@ -2,13 +2,14 @@ package channels
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
@ -31,21 +32,21 @@ type OneBotChannel struct {
} }
type oneBotRawEvent struct { type oneBotRawEvent struct {
PostType string `json:"post_type"` PostType string `json:"post_type"`
MessageType string `json:"message_type"` MessageType string `json:"message_type"`
SubType string `json:"sub_type"` SubType string `json:"sub_type"`
MessageID json.RawMessage `json:"message_id"` MessageID jsontext.Value `json:"message_id"`
UserID json.RawMessage `json:"user_id"` UserID jsontext.Value `json:"user_id"`
GroupID json.RawMessage `json:"group_id"` GroupID jsontext.Value `json:"group_id"`
RawMessage string `json:"raw_message"` RawMessage string `json:"raw_message"`
Message json.RawMessage `json:"message"` Message jsontext.Value `json:"message"`
Sender json.RawMessage `json:"sender"` Sender jsontext.Value `json:"sender"`
SelfID json.RawMessage `json:"self_id"` SelfID jsontext.Value `json:"self_id"`
Time json.RawMessage `json:"time"` Time jsontext.Value `json:"time"`
MetaEventType string `json:"meta_event_type"` MetaEventType string `json:"meta_event_type"`
Echo string `json:"echo"` Echo string `json:"echo"`
RetCode json.RawMessage `json:"retcode"` RetCode jsontext.Value `json:"retcode"`
Status BotStatus `json:"status"` Status BotStatus `json:"status"`
} }
type BotStatus struct { type BotStatus struct {
@ -54,9 +55,9 @@ type BotStatus struct {
} }
type oneBotSender struct { type oneBotSender struct {
UserID json.RawMessage `json:"user_id"` UserID jsontext.Value `json:"user_id"`
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
Card string `json:"card"` Card string `json:"card"`
} }
type oneBotEvent struct { type oneBotEvent struct {
@ -78,7 +79,7 @@ type oneBotEvent struct {
type oneBotAPIRequest struct { type oneBotAPIRequest struct {
Action string `json:"action"` Action string `json:"action"`
Params interface{} `json:"params"` Params interface{} `json:"params"`
Echo string `json:"echo,omitempty"` Echo string `json:"echo,omitzero"`
} }
type oneBotSendPrivateMsgParams struct { type oneBotSendPrivateMsgParams struct {
@ -236,7 +237,7 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
Echo: echo, Echo: echo,
} }
data, err := json.Marshal(req) data, err := jsonv2.Marshal(req)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal OneBot request: %w", err) return fmt.Errorf("failed to marshal OneBot request: %w", err)
} }
@ -326,7 +327,7 @@ func (c *OneBotChannel) listen() {
}) })
var raw oneBotRawEvent var raw oneBotRawEvent
if err := json.Unmarshal(message, &raw); err != nil { if err := jsonv2.Unmarshal(message, &raw); err != nil {
logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]interface{}{ logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
"payload": string(message), "payload": string(message),
@ -354,29 +355,29 @@ func (c *OneBotChannel) listen() {
} }
} }
func parseJSONInt64(raw json.RawMessage) (int64, error) { func parseJSONInt64(raw jsontext.Value) (int64, error) {
if len(raw) == 0 { if len(raw) == 0 {
return 0, nil return 0, nil
} }
var n int64 var n int64
if err := json.Unmarshal(raw, &n); err == nil { if err := jsonv2.Unmarshal(raw, &n); err == nil {
return n, nil return n, nil
} }
var s string var s string
if err := json.Unmarshal(raw, &s); err == nil { if err := jsonv2.Unmarshal(raw, &s); err == nil {
return strconv.ParseInt(s, 10, 64) return strconv.ParseInt(s, 10, 64)
} }
return 0, fmt.Errorf("cannot parse as int64: %s", string(raw)) return 0, fmt.Errorf("cannot parse as int64: %s", string(raw))
} }
func parseJSONString(raw json.RawMessage) string { func parseJSONString(raw jsontext.Value) string {
if len(raw) == 0 { if len(raw) == 0 {
return "" return ""
} }
var s string var s string
if err := json.Unmarshal(raw, &s); err == nil { if err := jsonv2.Unmarshal(raw, &s); err == nil {
return s return s
} }
@ -388,13 +389,13 @@ type parseMessageResult struct {
IsBotMentioned bool IsBotMentioned bool
} }
func parseMessageContentEx(raw json.RawMessage, selfID int64) parseMessageResult { func parseMessageContentEx(raw jsontext.Value, selfID int64) parseMessageResult {
if len(raw) == 0 { if len(raw) == 0 {
return parseMessageResult{} return parseMessageResult{}
} }
var s string var s string
if err := json.Unmarshal(raw, &s); err == nil { if err := jsonv2.Unmarshal(raw, &s); err == nil {
mentioned := false mentioned := false
if selfID > 0 { if selfID > 0 {
cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID) cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID)
@ -408,7 +409,7 @@ func parseMessageContentEx(raw json.RawMessage, selfID int64) parseMessageResult
} }
var segments []map[string]interface{} var segments []map[string]interface{}
if err := json.Unmarshal(raw, &segments); err == nil { if err := jsonv2.Unmarshal(raw, &segments); err == nil {
var text string var text string
mentioned := false mentioned := false
selfIDStr := strconv.FormatInt(selfID, 10) selfIDStr := strconv.FormatInt(selfID, 10)
@ -497,7 +498,7 @@ func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent
var sender oneBotSender var sender oneBotSender
if len(raw.Sender) > 0 { if len(raw.Sender) > 0 {
if err := json.Unmarshal(raw.Sender, &sender); err != nil { if err := jsonv2.Unmarshal(raw.Sender, &sender); err != nil {
logger.WarnCF("onebot", "Failed to parse sender", map[string]interface{}{ logger.WarnCF("onebot", "Failed to parse sender", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
"sender": string(raw.Sender), "sender": string(raw.Sender),

View file

@ -2,12 +2,12 @@ package channels
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"log" "log"
"sync" "sync"
"time" "time"
jsonv2 "github.com/go-json-experiment/json"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
@ -92,7 +92,7 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
"content": msg.Content, "content": msg.Content,
} }
data, err := json.Marshal(payload) data, err := jsonv2.Marshal(payload)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal message: %w", err) return fmt.Errorf("failed to marshal message: %w", err)
} }
@ -127,7 +127,7 @@ func (c *WhatsAppChannel) listen(ctx context.Context) {
} }
var msg map[string]interface{} var msg map[string]interface{}
if err := json.Unmarshal(message, &msg); err != nil { if err := jsonv2.Unmarshal(message, &msg); err != nil {
log.Printf("Failed to unmarshal WhatsApp message: %v", err) log.Printf("Failed to unmarshal WhatsApp message: %v", err)
continue continue
} }

View file

@ -1,13 +1,14 @@
package config package config
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
) )
// FlexibleStringSlice is a []string that also accepts JSON numbers, // FlexibleStringSlice is a []string that also accepts JSON numbers,
@ -17,14 +18,14 @@ type FlexibleStringSlice []string
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
// Try []string first // Try []string first
var ss []string var ss []string
if err := json.Unmarshal(data, &ss); err == nil { if err := jsonv2.Unmarshal(data, &ss); err == nil {
*f = ss *f = ss
return nil return nil
} }
// Try []interface{} to handle mixed types // Try []interface{} to handle mixed types
var raw []interface{} var raw []interface{}
if err := json.Unmarshal(data, &raw); err != nil { if err := jsonv2.Unmarshal(data, &raw); err != nil {
return err return err
} }
@ -251,10 +252,10 @@ type ProvidersConfig struct {
type ProviderConfig struct { type ProviderConfig struct {
APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"` APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"` APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"` Proxy string `json:"proxy,omitzero" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"` AuthMethod string `json:"auth_method,omitzero" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
Timeout int `json:"timeout,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_TIMEOUT"` // seconds, 0 = default (120s) Timeout int `json:"timeout,omitzero" env:"PICOCLAW_PROVIDERS_{{.Name}}_TIMEOUT"` // seconds, 0 = default (120s)
ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc` ConnectMode string `json:"connect_mode,omitzero" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
} }
type OpenAIProviderConfig struct { type OpenAIProviderConfig struct {
@ -450,7 +451,7 @@ func LoadConfig(path string) (*Config, error) {
return nil, err return nil, err
} }
if err := json.Unmarshal(data, cfg); err != nil { if err := jsonv2.Unmarshal(data, cfg); err != nil {
return nil, err return nil, err
} }
@ -522,7 +523,7 @@ func SaveConfig(path string, cfg *Config) error {
cfg.mu.RLock() cfg.mu.RLock()
defer cfg.mu.RUnlock() defer cfg.mu.RUnlock()
data, err := json.MarshalIndent(cfg, "", " ") data, err := jsonv2.Marshal(cfg, jsontext.WithIndent(" "))
if err != nil { if err != nil {
return err return err
} }

View file

@ -4,7 +4,6 @@ import (
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"log" "log"
"os" "os"
@ -13,31 +12,34 @@ import (
"time" "time"
"github.com/adhocore/gronx" "github.com/adhocore/gronx"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"github.com/sipeed/picoclaw/pkg/memory" "github.com/sipeed/picoclaw/pkg/memory"
) )
type CronSchedule struct { type CronSchedule struct {
Kind string `json:"kind"` Kind string `json:"kind"`
AtMS *int64 `json:"atMs,omitempty"` AtMS *int64 `json:"atMs,omitzero"`
EveryMS *int64 `json:"everyMs,omitempty"` EveryMS *int64 `json:"everyMs,omitzero"`
Expr string `json:"expr,omitempty"` Expr string `json:"expr,omitzero"`
TZ string `json:"tz,omitempty"` TZ string `json:"tz,omitzero"`
} }
type CronPayload struct { type CronPayload struct {
Kind string `json:"kind"` Kind string `json:"kind"`
Message string `json:"message"` Message string `json:"message"`
Command string `json:"command,omitempty"` Command string `json:"command,omitzero"`
Deliver bool `json:"deliver"` Deliver bool `json:"deliver"`
Channel string `json:"channel,omitempty"` Channel string `json:"channel,omitzero"`
To string `json:"to,omitempty"` To string `json:"to,omitzero"`
} }
type CronJobState struct { type CronJobState struct {
NextRunAtMS *int64 `json:"nextRunAtMs,omitempty"` NextRunAtMS *int64 `json:"nextRunAtMs,omitzero"`
LastRunAtMS *int64 `json:"lastRunAtMs,omitempty"` LastRunAtMS *int64 `json:"lastRunAtMs,omitzero"`
LastStatus string `json:"lastStatus,omitempty"` LastStatus string `json:"lastStatus,omitzero"`
LastError string `json:"lastError,omitempty"` LastError string `json:"lastError,omitzero"`
} }
type CronJob struct { type CronJob struct {
@ -349,7 +351,7 @@ func (cs *CronService) loadStore() error {
return err return err
} }
return json.Unmarshal(data, cs.store) return jsonv2.Unmarshal(data, cs.store)
} }
func (cs *CronService) loadStoreFromDelegate() error { func (cs *CronService) loadStoreFromDelegate() error {
@ -360,7 +362,7 @@ func (cs *CronService) loadStoreFromDelegate() error {
if err != nil || val == "" { if err != nil || val == "" {
return nil return nil
} }
return json.Unmarshal([]byte(val), cs.store) return jsonv2.Unmarshal([]byte(val), cs.store)
} }
func (cs *CronService) saveStoreUnsafe() error { func (cs *CronService) saveStoreUnsafe() error {
@ -373,7 +375,7 @@ func (cs *CronService) saveStoreUnsafe() error {
return err return err
} }
data, err := json.MarshalIndent(cs.store, "", " ") data, err := jsonv2.Marshal(cs.store, jsontext.WithIndent(" "))
if err != nil { if err != nil {
return err return err
} }
@ -382,7 +384,7 @@ func (cs *CronService) saveStoreUnsafe() error {
} }
func (cs *CronService) saveStoreToDelegate() error { func (cs *CronService) saveStoreToDelegate() error {
data, err := json.Marshal(cs.store) data, err := jsonv2.Marshal(cs.store)
if err != nil { if err != nil {
return err return err
} }

View file

@ -7,10 +7,11 @@ package fantasy
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"charm.land/fantasy" "charm.land/fantasy"
jsonv2 "github.com/go-json-experiment/json"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
memstore "github.com/sipeed/picoclaw/pkg/memory/store" memstore "github.com/sipeed/picoclaw/pkg/memory/store"
@ -170,7 +171,7 @@ func parseToolArgs(input string) (map[string]interface{}, error) {
} }
var args map[string]interface{} var args map[string]interface{}
if err := json.Unmarshal([]byte(input), &args); err != nil { if err := jsonv2.Unmarshal([]byte(input), &args); err != nil {
return nil, fmt.Errorf("failed to parse tool arguments: %w", err) return nil, fmt.Errorf("failed to parse tool arguments: %w", err)
} }
return args, nil return args, nil

View file

@ -8,13 +8,13 @@ package fantasy
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"iter" "iter"
"os/exec" "os/exec"
"strings" "strings"
"charm.land/fantasy" "charm.land/fantasy"
jsonv2 "github.com/go-json-experiment/json"
) )
// claudeCliProvider implements fantasy.Provider using the claude CLI subprocess. // claudeCliProvider implements fantasy.Provider using the claude CLI subprocess.
@ -147,7 +147,7 @@ func extractTextFromCLIOutput(output []byte) string {
Content string `json:"content"` Content string `json:"content"`
Text string `json:"text"` Text string `json:"text"`
} }
if err := json.Unmarshal(output, &results); err == nil { if err := jsonv2.Unmarshal(output, &results); err == nil {
var texts []string var texts []string
for _, r := range results { for _, r := range results {
switch { switch {
@ -167,7 +167,7 @@ func extractTextFromCLIOutput(output []byte) string {
Result string `json:"result"` Result string `json:"result"`
Text string `json:"text"` Text string `json:"text"`
} }
if err := json.Unmarshal(output, &single); err == nil { if err := jsonv2.Unmarshal(output, &single); err == nil {
if single.Result != "" { if single.Result != "" {
return single.Result return single.Result
} }

View file

@ -6,9 +6,9 @@
package fantasy package fantasy
import ( import (
"encoding/json"
"charm.land/fantasy" "charm.land/fantasy"
jsonv2 "github.com/go-json-experiment/json"
"github.com/sipeed/picoclaw/pkg/messages" "github.com/sipeed/picoclaw/pkg/messages"
) )
@ -55,7 +55,7 @@ func MessageToFantasy(msg messages.Message) fantasy.Message {
input = tc.Function.Arguments input = tc.Function.Arguments
} else if tc.Arguments != nil { } else if tc.Arguments != nil {
// Fallback: serialize the map to JSON. // Fallback: serialize the map to JSON.
data, _ := json.Marshal(tc.Arguments) data, _ := jsonv2.Marshal(tc.Arguments)
input = string(data) input = string(data)
} }

View file

@ -2,11 +2,12 @@ package health
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"sync" "sync"
"time" "time"
jsonv2 "github.com/go-json-experiment/json"
) )
type Server struct { type Server struct {
@ -20,14 +21,14 @@ type Server struct {
type Check struct { type Check struct {
Name string `json:"name"` Name string `json:"name"`
Status string `json:"status"` Status string `json:"status"`
Message string `json:"message,omitempty"` Message string `json:"message,omitzero"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
} }
type StatusResponse struct { type StatusResponse struct {
Status string `json:"status"` Status string `json:"status"`
Uptime string `json:"uptime"` Uptime string `json:"uptime"`
Checks map[string]Check `json:"checks,omitempty"` Checks map[string]Check `json:"checks,omitzero"`
} }
func NewServer(host string, port int) *Server { func NewServer(host string, port int) *Server {
@ -113,7 +114,7 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
Uptime: uptime.String(), Uptime: uptime.String(),
} }
json.NewEncoder(w).Encode(resp) jsonv2.MarshalWrite(w, resp)
} }
func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) { func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
@ -129,7 +130,7 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
if !ready { if !ready {
w.WriteHeader(http.StatusServiceUnavailable) w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(StatusResponse{ jsonv2.MarshalWrite(w, StatusResponse{
Status: "not ready", Status: "not ready",
Checks: checks, Checks: checks,
}) })
@ -139,7 +140,7 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
for _, check := range checks { for _, check := range checks {
if check.Status == "fail" { if check.Status == "fail" {
w.WriteHeader(http.StatusServiceUnavailable) w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(StatusResponse{ jsonv2.MarshalWrite(w, StatusResponse{
Status: "not ready", Status: "not ready",
Checks: checks, Checks: checks,
}) })
@ -149,7 +150,7 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
uptime := time.Since(s.startTime) uptime := time.Since(s.startTime)
json.NewEncoder(w).Encode(StatusResponse{ jsonv2.MarshalWrite(w, StatusResponse{
Status: "ready", Status: "ready",
Uptime: uptime.String(), Uptime: uptime.String(),
Checks: checks, Checks: checks,

View file

@ -1,8 +1,9 @@
package ids package ids
import ( import (
"encoding/json"
"testing" "testing"
jsonv2 "github.com/go-json-experiment/json"
) )
func TestNew_IsV7(t *testing.T) { func TestNew_IsV7(t *testing.T) {
@ -159,14 +160,14 @@ func TestScan_InvalidType(t *testing.T) {
func TestJSON_RoundTrip(t *testing.T) { func TestJSON_RoundTrip(t *testing.T) {
u := New() u := New()
b, err := json.Marshal(u) b, err := jsonv2.Marshal(u)
if err != nil { if err != nil {
t.Fatalf("Marshal: %v", err) t.Fatalf("Marshal: %v", err)
} }
// Should be a quoted string // Should be a quoted string
var s string var s string
if err := json.Unmarshal(b, &s); err != nil { if err := jsonv2.Unmarshal(b, &s); err != nil {
t.Fatalf("Unmarshal to string: %v", err) t.Fatalf("Unmarshal to string: %v", err)
} }
if s != u.String() { if s != u.String() {
@ -174,7 +175,7 @@ func TestJSON_RoundTrip(t *testing.T) {
} }
var parsed UUID var parsed UUID
if err := json.Unmarshal(b, &parsed); err != nil { if err := jsonv2.Unmarshal(b, &parsed); err != nil {
t.Fatalf("Unmarshal: %v", err) t.Fatalf("Unmarshal: %v", err)
} }
if parsed != u { if parsed != u {
@ -184,7 +185,7 @@ func TestJSON_RoundTrip(t *testing.T) {
func TestJSON_ZeroUUID(t *testing.T) { func TestJSON_ZeroUUID(t *testing.T) {
var zero UUID var zero UUID
b, err := json.Marshal(zero) b, err := jsonv2.Marshal(zero)
if err != nil { if err != nil {
t.Fatalf("Marshal zero: %v", err) t.Fatalf("Marshal zero: %v", err)
} }
@ -201,13 +202,13 @@ func TestJSON_InStruct(t *testing.T) {
} }
r := record{ID: New(), Name: "test"} r := record{ID: New(), Name: "test"}
b, err := json.Marshal(r) b, err := jsonv2.Marshal(r)
if err != nil { if err != nil {
t.Fatalf("Marshal struct: %v", err) t.Fatalf("Marshal struct: %v", err)
} }
var decoded record var decoded record
if err := json.Unmarshal(b, &decoded); err != nil { if err := jsonv2.Unmarshal(b, &decoded); err != nil {
t.Fatalf("Unmarshal struct: %v", err) t.Fatalf("Unmarshal struct: %v", err)
} }
if decoded.ID != r.ID { if decoded.ID != r.ID {

View file

@ -2,9 +2,10 @@ package ids
import ( import (
"database/sql/driver" "database/sql/driver"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
) )
// UUID represents a 16-byte RFC-9562 UUIDv7 value. // UUID represents a 16-byte RFC-9562 UUIDv7 value.
@ -85,13 +86,13 @@ func (u *UUID) Scan(src interface{}) error {
// MarshalJSON encodes UUID as JSON string. // MarshalJSON encodes UUID as JSON string.
func (u UUID) MarshalJSON() ([]byte, error) { func (u UUID) MarshalJSON() ([]byte, error) {
return json.Marshal(u.String()) return jsonv2.Marshal(u.String())
} }
// UnmarshalJSON decodes UUID from JSON string. // UnmarshalJSON decodes UUID from JSON string.
func (u *UUID) UnmarshalJSON(b []byte) error { func (u *UUID) UnmarshalJSON(b []byte) error {
var s string var s string
if err := json.Unmarshal(b, &s); err != nil { if err := jsonv2.Unmarshal(b, &s); err != nil {
return err return err
} }
parsed, err := Parse(s) parsed, err := Parse(s)

View file

@ -3,20 +3,15 @@
// recursive decomposition operations — are serialized as ToolRequest / // recursive decomposition operations — are serialized as ToolRequest /
// ToolResponse pairs. // ToolResponse pairs.
// //
// The canonical schema lives in commands.fbs. This file provides the Go // The canonical schema lives in commands.fbs. Wire encoding uses FlatBuffers
// types and encoding helpers that replace the raw flatc-generated code while // for all internal transport (socket, daemon, WASM). JSON encoding is
// keeping the same wire format semantics. The encoding uses encoding/json // available via MarshalJSON / UnmarshalRequestJSON for the LLM boundary only.
// on the initial implementation path; the FlatBuffers binary encoding is
// available via the flatbuffers package when performance demands it.
//
// Migration path: when the codebase moves to the full FlatBuffers binary
// encoding, replace the JSON marshal/unmarshal calls with flatbuffers builder
// calls without changing any call sites — the public types remain stable.
package itr package itr
import ( import (
"encoding/json"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"time" "time"
) )
@ -113,6 +108,10 @@ type CodeExec struct {
// command payload and declares dependencies on other nodes by ID. // command payload and declares dependencies on other nodes by ID.
// Args may contain "#nodeN" references that are resolved to the output of // Args may contain "#nodeN" references that are resolved to the output of
// the dependency node at execution time. // the dependency node at execution time.
//
// Implements json.Unmarshaler to deserialize Payload directly into the
// concrete type indicated by Type, avoiding the map[string]interface{}
// erasure that encoding/json applies to interface{} fields.
type DAGNode struct { type DAGNode struct {
ID string `json:"id"` ID string `json:"id"`
Type CommandType `json:"type"` Type CommandType `json:"type"`
@ -120,6 +119,92 @@ type DAGNode struct {
DependsOn []string `json:"depends_on,omitempty"` DependsOn []string `json:"depends_on,omitempty"`
} }
// UnmarshalJSONFrom implements jsonv2.UnmarshalerFrom. It defers Payload
// deserialization via jsontext.Value until the Type discriminator is known,
// then unmarshals directly into the concrete Go type.
func (n *DAGNode) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
var raw struct {
ID string `json:"id"`
Type CommandType `json:"type"`
Payload jsontext.Value `json:"payload"`
DependsOn []string `json:"depends_on,omitempty"`
}
if err := jsonv2.UnmarshalDecode(dec, &raw); err != nil {
return err
}
n.ID = raw.ID
n.Type = raw.Type
n.DependsOn = raw.DependsOn
if len(raw.Payload) == 0 || string(raw.Payload) == "null" {
return nil
}
unmarshalPayload := func(dst any) error {
return jsonv2.Unmarshal(raw.Payload, dst, jsonv2.MatchCaseInsensitiveNames(true))
}
switch raw.Type {
case CmdToolExec:
var p ToolExec
if err := unmarshalPayload(&p); err != nil {
return fmt.Errorf("DAGNode %s payload: %w", raw.ID, err)
}
n.Payload = p
case CmdToolSearch:
var p ToolSearch
if err := unmarshalPayload(&p); err != nil {
return fmt.Errorf("DAGNode %s payload: %w", raw.ID, err)
}
n.Payload = p
case CmdPeek:
var p Peek
if err := unmarshalPayload(&p); err != nil {
return fmt.Errorf("DAGNode %s payload: %w", raw.ID, err)
}
n.Payload = p
case CmdGrep:
var p Grep
if err := unmarshalPayload(&p); err != nil {
return fmt.Errorf("DAGNode %s payload: %w", raw.ID, err)
}
n.Payload = p
case CmdPartition:
var p Partition
if err := unmarshalPayload(&p); err != nil {
return fmt.Errorf("DAGNode %s payload: %w", raw.ID, err)
}
n.Payload = p
case CmdRecurse:
var p Recurse
if err := unmarshalPayload(&p); err != nil {
return fmt.Errorf("DAGNode %s payload: %w", raw.ID, err)
}
n.Payload = p
case CmdCodeExec:
var p CodeExec
if err := unmarshalPayload(&p); err != nil {
return fmt.Errorf("DAGNode %s payload: %w", raw.ID, err)
}
n.Payload = p
case CmdExecWasm:
var p ExecWasm
if err := unmarshalPayload(&p); err != nil {
return fmt.Errorf("DAGNode %s payload: %w", raw.ID, err)
}
n.Payload = p
case CmdFinal:
var p Final
if err := unmarshalPayload(&p); err != nil {
return fmt.Errorf("DAGNode %s payload: %w", raw.ID, err)
}
n.Payload = p
default:
return fmt.Errorf("DAGNode %s: unknown type %q", raw.ID, raw.Type)
}
return nil
}
// DAGPlan is a composite command that contains multiple DAGNodes forming a // DAGPlan is a composite command that contains multiple DAGNodes forming a
// Directed Acyclic Graph. The executor dispatches nodes in topological order, // Directed Acyclic Graph. The executor dispatches nodes in topological order,
// running independent nodes in parallel. // running independent nodes in parallel.
@ -269,30 +354,77 @@ func NewLeakResponse(id, redactedResult string, redactedKeys []string) ToolRespo
} }
} }
// ── Serialisation helpers ──────────────────────────────────────────────────── // ── Serialisation helpers (FlatBuffers default) ──────────────────────────────
// Marshal encodes a ToolRequest to JSON bytes for transmission. // Marshal encodes a ToolRequest to FlatBuffers bytes for internal transport.
func (r ToolRequest) Marshal() ([]byte, error) { func (r ToolRequest) Marshal() ([]byte, error) {
return json.Marshal(r) return MarshalRequestFB(r)
} }
// Marshal encodes a ToolResponse to JSON bytes for transmission. // Marshal encodes a ToolResponse to FlatBuffers bytes for internal transport.
func (r ToolResponse) Marshal() ([]byte, error) { func (r ToolResponse) Marshal() ([]byte, error) {
return json.Marshal(r) return MarshalResponseFB(r)
} }
// UnmarshalRequest decodes a ToolRequest from JSON bytes. // UnmarshalRequest decodes a ToolRequest from FlatBuffers bytes.
func UnmarshalRequest(data []byte) (ToolRequest, error) { func UnmarshalRequest(data []byte) (ToolRequest, error) {
var raw struct { return UnmarshalRequestFB(data)
ID string `json:"id"` }
Type CommandType `json:"type"`
Payload json.RawMessage `json:"payload"` // UnmarshalResponse decodes a ToolResponse from FlatBuffers bytes.
Timestamp int64 `json:"timestamp"` func UnmarshalResponse(data []byte) (ToolResponse, error) {
Depth uint8 `json:"depth"` return UnmarshalResponseFB(data)
SessionKey string `json:"session_key"` }
ToolCallID string `json:"tool_call_id"`
// ── JSON helpers (LLM boundary only) ─────────────────────────────────────────
// MarshalJSONTo implements jsonv2.MarshalerTo. Use only at the LLM boundary.
func (r ToolRequest) MarshalJSONTo(enc *jsontext.Encoder) error {
type alias struct {
ID string `json:"id"`
Type CommandType `json:"type"`
Payload interface{} `json:"payload"`
Timestamp int64 `json:"timestamp"`
Depth uint8 `json:"depth,omitzero"`
SessionKey string `json:"session_key,omitzero"`
ToolCallID string `json:"tool_call_id,omitzero"`
} }
if err := json.Unmarshal(data, &raw); err != nil { return jsonv2.MarshalEncode(enc, alias{r.ID, r.Type, r.Payload, r.Timestamp, r.Depth, r.SessionKey, r.ToolCallID})
}
// MarshalJSONTo implements jsonv2.MarshalerTo. Use only at the LLM boundary.
func (r ToolResponse) MarshalJSONTo(enc *jsontext.Encoder) error {
type alias struct {
ID string `json:"id"`
Result string `json:"result,omitzero"`
IsError bool `json:"is_error,omitzero"`
LeakDetected bool `json:"leak_detected,omitzero"`
CostTokens uint32 `json:"cost_tokens,omitzero"`
RedactedKeys []string `json:"redacted_keys,omitzero"`
}
return jsonv2.MarshalEncode(enc, alias{r.ID, r.Result, r.IsError, r.LeakDetected, r.CostTokens, r.RedactedKeys})
}
// LLMJSONOpts returns json/v2 options suitable for unmarshaling LLM-generated JSON,
// which may use inconsistent casing.
func LLMJSONOpts() jsonv2.Options {
return jsonv2.MatchCaseInsensitiveNames(true)
}
// UnmarshalRequestJSON decodes a ToolRequest from JSON bytes. Use only at
// the LLM boundary (planner output, tool schemas).
func UnmarshalRequestJSON(data []byte) (ToolRequest, error) {
opts := LLMJSONOpts()
var raw struct {
ID string `json:"id"`
Type CommandType `json:"type"`
Payload jsontext.Value `json:"payload"`
Timestamp int64 `json:"timestamp"`
Depth uint8 `json:"depth"`
SessionKey string `json:"session_key"`
ToolCallID string `json:"tool_call_id"`
}
if err := jsonv2.Unmarshal(data, &raw, opts); err != nil {
return ToolRequest{}, err return ToolRequest{}, err
} }
@ -305,47 +437,51 @@ func UnmarshalRequest(data []byte) (ToolRequest, error) {
ToolCallID: raw.ToolCallID, ToolCallID: raw.ToolCallID,
} }
unmarshalPayload := func(dst any) error {
return jsonv2.Unmarshal(raw.Payload, dst, opts)
}
var err error var err error
switch raw.Type { switch raw.Type {
case CmdPeek: case CmdPeek:
var p Peek var p Peek
err = json.Unmarshal(raw.Payload, &p) err = unmarshalPayload(&p)
req.Payload = p req.Payload = p
case CmdGrep: case CmdGrep:
var p Grep var p Grep
err = json.Unmarshal(raw.Payload, &p) err = unmarshalPayload(&p)
req.Payload = p req.Payload = p
case CmdPartition: case CmdPartition:
var p Partition var p Partition
err = json.Unmarshal(raw.Payload, &p) err = unmarshalPayload(&p)
req.Payload = p req.Payload = p
case CmdRecurse: case CmdRecurse:
var p Recurse var p Recurse
err = json.Unmarshal(raw.Payload, &p) err = unmarshalPayload(&p)
req.Payload = p req.Payload = p
case CmdToolExec: case CmdToolExec:
var p ToolExec var p ToolExec
err = json.Unmarshal(raw.Payload, &p) err = unmarshalPayload(&p)
req.Payload = p req.Payload = p
case CmdExecWasm: case CmdExecWasm:
var p ExecWasm var p ExecWasm
err = json.Unmarshal(raw.Payload, &p) err = unmarshalPayload(&p)
req.Payload = p req.Payload = p
case CmdFinal: case CmdFinal:
var p Final var p Final
err = json.Unmarshal(raw.Payload, &p) err = unmarshalPayload(&p)
req.Payload = p req.Payload = p
case CmdToolSearch: case CmdToolSearch:
var p ToolSearch var p ToolSearch
err = json.Unmarshal(raw.Payload, &p) err = unmarshalPayload(&p)
req.Payload = p req.Payload = p
case CmdCodeExec: case CmdCodeExec:
var p CodeExec var p CodeExec
err = json.Unmarshal(raw.Payload, &p) err = unmarshalPayload(&p)
req.Payload = p req.Payload = p
case CmdDAGPlan: case CmdDAGPlan:
var p DAGPlan var p DAGPlan
err = json.Unmarshal(raw.Payload, &p) err = unmarshalPayload(&p)
req.Payload = p req.Payload = p
default: default:
return ToolRequest{}, fmt.Errorf("unknown command type: %q", raw.Type) return ToolRequest{}, fmt.Errorf("unknown command type: %q", raw.Type)
@ -354,8 +490,8 @@ func UnmarshalRequest(data []byte) (ToolRequest, error) {
return req, err return req, err
} }
// UnmarshalResponse decodes a ToolResponse from JSON bytes. // UnmarshalResponseJSON decodes a ToolResponse from JSON bytes.
func UnmarshalResponse(data []byte) (ToolResponse, error) { func UnmarshalResponseJSON(data []byte) (ToolResponse, error) {
var r ToolResponse var r ToolResponse
return r, json.Unmarshal(data, &r) return r, jsonv2.Unmarshal(data, &r)
} }

View file

@ -13,7 +13,6 @@ package dag
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"runtime" "runtime"
"strings" "strings"
@ -36,11 +35,11 @@ type RLMExpandFunc func(ctx context.Context, sessionKey, query, contextContent s
// Executor runs a DAGPlan through the SecureBus with topological dispatch. // Executor runs a DAGPlan through the SecureBus with topological dispatch.
type Executor struct { type Executor struct {
bus *securebus.Bus bus *securebus.Bus
joiner JoinerFunc joiner JoinerFunc
rlmExpand RLMExpandFunc rlmExpand RLMExpandFunc
rlmThresholdBytes int rlmThresholdBytes int
maxParallel int maxParallel int
} }
// ExecutorOption configures an Executor via the functional options pattern. // ExecutorOption configures an Executor via the functional options pattern.
@ -133,8 +132,9 @@ func (e *Executor) Execute(ctx context.Context, sessionKey string, plan *itr.DAG
select { select {
case <-depState.done: case <-depState.done:
if _, depErr := depState.getResult(); depErr != nil { if _, depErr := depState.getResult(); depErr != nil {
ns.setResult("", fmt.Errorf("dependency %s failed: %w", dep, depErr)) wrapped := fmt.Errorf("dependency %s failed: %w", dep, depErr)
errOnce.Do(func() { waveErr = depErr }) ns.setResult("", wrapped)
errOnce.Do(func() { waveErr = wrapped })
return return
} }
case <-ctx.Done(): case <-ctx.Done():
@ -215,34 +215,31 @@ func (e *Executor) Execute(ctx context.Context, sessionKey string, plan *itr.DAG
// executeNode dispatches a single node through the SecureBus after resolving // executeNode dispatches a single node through the SecureBus after resolving
// dependency references in its payload. // dependency references in its payload.
func (e *Executor) executeNode(ctx context.Context, sessionKey string, node *itr.DAGNode, states map[string]*nodeState) itr.ToolResponse { func (e *Executor) executeNode(ctx context.Context, sessionKey string, node *itr.DAGNode, states map[string]*nodeState) itr.ToolResponse {
req := nodeToRequest(sessionKey, node, states) req, err := nodeToRequest(sessionKey, node, states)
if err != nil {
return itr.NewErrorResponse(node.ID, err.Error())
}
return e.bus.Execute(ctx, req) return e.bus.Execute(ctx, req)
} }
// nodeToRequest converts a DAGNode into a ToolRequest, resolving #nodeN // nodeToRequest converts a DAGNode into a ToolRequest, resolving #nodeN
// references in tool arguments. // references in tool arguments.
func nodeToRequest(sessionKey string, node *itr.DAGNode, states map[string]*nodeState) itr.ToolRequest { func nodeToRequest(sessionKey string, node *itr.DAGNode, states map[string]*nodeState) (itr.ToolRequest, error) {
switch node.Type { switch node.Type {
case itr.CmdToolExec: case itr.CmdToolExec:
te, ok := node.Payload.(itr.ToolExec) te, ok := node.Payload.(itr.ToolExec)
if !ok { if !ok {
if m, ok := node.Payload.(map[string]interface{}); ok { return itr.ToolRequest{}, fmt.Errorf("node %s: expected ToolExec payload, got %T", node.ID, node.Payload)
b, _ := json.Marshal(m)
_ = json.Unmarshal(b, &te)
}
} }
te.ArgsJSON = resolveToolExecArgs(te.ArgsJSON, states) te.ArgsJSON = resolveToolExecArgs(te.ArgsJSON, states)
return itr.NewToolExecRequest(node.ID, sessionKey, node.ID, te.ToolName, te.ArgsJSON) return itr.NewToolExecRequest(node.ID, sessionKey, node.ID, te.ToolName, te.ArgsJSON), nil
case itr.CmdToolSearch: case itr.CmdToolSearch:
ts, ok := node.Payload.(itr.ToolSearch) ts, ok := node.Payload.(itr.ToolSearch)
if !ok { if !ok {
if m, ok := node.Payload.(map[string]interface{}); ok { return itr.ToolRequest{}, fmt.Errorf("node %s: expected ToolSearch payload, got %T", node.ID, node.Payload)
b, _ := json.Marshal(m)
_ = json.Unmarshal(b, &ts)
}
} }
return itr.NewToolSearchRequest(node.ID, sessionKey, ts.Query, ts.MaxResults) return itr.NewToolSearchRequest(node.ID, sessionKey, ts.Query, ts.MaxResults), nil
default: default:
return itr.ToolRequest{ return itr.ToolRequest{
@ -250,7 +247,7 @@ func nodeToRequest(sessionKey string, node *itr.DAGNode, states map[string]*node
Type: node.Type, Type: node.Type,
Payload: node.Payload, Payload: node.Payload,
SessionKey: sessionKey, SessionKey: sessionKey,
} }, nil
} }
} }

View file

@ -2,8 +2,8 @@ package dag_test
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"testing" "testing"
"github.com/sipeed/picoclaw/pkg/itr" "github.com/sipeed/picoclaw/pkg/itr"
@ -123,7 +123,11 @@ func TestExecutor_WithJoiner(t *testing.T) {
defer bus.Close() defer bus.Close()
joiner := func(_ context.Context, _, userQuery string) (string, uint32, error) { joiner := func(_ context.Context, _, userQuery string) (string, uint32, error) {
return "synthesized: " + userQuery[:20], 50, nil n := len(userQuery)
if n > 20 {
n = 20
}
return "synthesized: " + userQuery[:n], 50, nil
} }
executor := dag.NewExecutor(bus, joiner) executor := dag.NewExecutor(bus, joiner)
@ -173,7 +177,8 @@ func TestResolver_NodeRefSubstitution(t *testing.T) {
result, err := executor.Execute(context.Background(), "test-sess", plan) result, err := executor.Execute(context.Background(), "test-sess", plan)
require.NoError(t, err) require.NoError(t, err)
_ = result assert.Contains(t, result.NodeResults["prev"], "previous-output")
assert.Contains(t, result.NodeResults["search"], "found")
} }
func TestRouter_SimpleQuerySelectsReAct(t *testing.T) { func TestRouter_SimpleQuerySelectsReAct(t *testing.T) {
@ -225,7 +230,7 @@ func TestPlanner_ValidatePlan(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
var plan itr.DAGPlan var plan itr.DAGPlan
err := json.Unmarshal([]byte(tt.plan), &plan) err := jsonv2.Unmarshal([]byte(tt.plan), &plan)
require.NoError(t, err) require.NoError(t, err)
// Use planner with a mock that returns the pre-built plan JSON // Use planner with a mock that returns the pre-built plan JSON

View file

@ -2,8 +2,8 @@ package dag
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"github.com/sipeed/picoclaw/pkg/itr" "github.com/sipeed/picoclaw/pkg/itr"
"github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/tools"
@ -17,10 +17,10 @@ type PlannerFunc func(ctx context.Context, systemPrompt, userQuery string) (stri
// LLM to produce a structured JSON plan. The LLM outputs a list of nodes // LLM to produce a structured JSON plan. The LLM outputs a list of nodes
// with dependency edges in a single inference pass (LLMCompiler pattern). // with dependency edges in a single inference pass (LLMCompiler pattern).
type Planner struct { type Planner struct {
callModel PlannerFunc callModel PlannerFunc
registry *tools.ToolRegistry registry *tools.ToolRegistry
maxParallel uint8 maxParallel uint8
tokenBudget uint32 tokenBudget uint32
} }
// PlannerConfig configures the DAG planner. // PlannerConfig configures the DAG planner.
@ -115,32 +115,10 @@ func parsePlanResponse(response string) (*itr.DAGPlan, error) {
response = extractJSON(response) response = extractJSON(response)
var plan itr.DAGPlan var plan itr.DAGPlan
if err := json.Unmarshal([]byte(response), &plan); err != nil { if err := jsonv2.Unmarshal([]byte(response), &plan, itr.LLMJSONOpts()); err != nil {
return nil, fmt.Errorf("JSON parse error: %w\nraw: %s", err, truncate(response, 500)) return nil, fmt.Errorf("JSON parse error: %w\nraw: %s", err, truncate(response, 500))
} }
// Unmarshal re-encodes Payload as map[string]interface{} via JSON round-trip.
// Convert payload maps to their proper types.
for i := range plan.Nodes {
n := &plan.Nodes[i]
m, ok := n.Payload.(map[string]interface{})
if !ok {
continue
}
b, _ := json.Marshal(m)
switch n.Type {
case itr.CmdToolExec:
var te itr.ToolExec
_ = json.Unmarshal(b, &te)
n.Payload = te
case itr.CmdToolSearch:
var ts itr.ToolSearch
_ = json.Unmarshal(b, &ts)
n.Payload = ts
}
}
return &plan, nil return &plan, nil
} }

View file

@ -1,8 +1,8 @@
package dag package dag
import ( import (
"encoding/json"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
@ -67,11 +67,10 @@ func resolveToolExecArgs(argsJSON string, states map[string]*nodeState) string {
// escapeForJSON makes a string safe for embedding into a JSON value. // escapeForJSON makes a string safe for embedding into a JSON value.
func escapeForJSON(s string) string { func escapeForJSON(s string) string {
b, err := json.Marshal(s) b, err := jsonv2.Marshal(s)
if err != nil { if err != nil || len(b) < 2 {
return s return s
} }
// Strip surrounding quotes since we're replacing within an existing string.
return string(b[1 : len(b)-1]) return string(b[1 : len(b)-1])
} }

View file

@ -1,7 +1,6 @@
package logger package logger
import ( import (
"encoding/json"
"fmt" "fmt"
"log" "log"
"os" "os"
@ -9,6 +8,8 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
jsonv2 "github.com/go-json-experiment/json"
) )
type LogLevel int type LogLevel int
@ -43,10 +44,10 @@ type Logger struct {
type LogEntry struct { type LogEntry struct {
Level string `json:"level"` Level string `json:"level"`
Timestamp string `json:"timestamp"` Timestamp string `json:"timestamp"`
Component string `json:"component,omitempty"` Component string `json:"component,omitzero"`
Message string `json:"message"` Message string `json:"message"`
Fields map[string]interface{} `json:"fields,omitempty"` Fields map[string]interface{} `json:"fields,omitzero"`
Caller string `json:"caller,omitempty"` Caller string `json:"caller,omitzero"`
} }
func init() { func init() {
@ -117,7 +118,7 @@ func logMessage(level LogLevel, component string, message string, fields map[str
} }
if logger.file != nil { if logger.file != nil {
jsonData, err := json.Marshal(entry) jsonData, err := jsonv2.Marshal(entry)
if err == nil { if err == nil {
logger.file.WriteString(string(jsonData) + "\n") logger.file.WriteString(string(jsonData) + "\n")
} }

View file

@ -3,12 +3,12 @@ package dag
// BudgetConfig defines the percentage allocation for each context section. // BudgetConfig defines the percentage allocation for each context section.
// All percentages should sum to 100. // All percentages should sum to 100.
type BudgetConfig struct { type BudgetConfig struct {
SystemPromptPct int // % for system prompt (identity, rules, skills) SystemPromptPct int // % for system prompt (identity, rules, skills)
ObservationsPct int // % for observation block ObservationsPct int // % for observation block
KnowledgePct int // % for knowledge block (Focus completions) KnowledgePct int // % for knowledge block (Focus completions)
DAGSummariesPct int // % for DAG compressed history DAGSummariesPct int // % for DAG compressed history
RawTailPct int // % for raw recent messages (uncompressed tail) RawTailPct int // % for raw recent messages (uncompressed tail)
ToolResultsPct int // % for tool call results ToolResultsPct int // % for tool call results
} }
// DefaultBudgetConfig returns a balanced allocation. // DefaultBudgetConfig returns a balanced allocation.

View file

@ -14,10 +14,10 @@ type Message struct {
// CompressorConfig controls the deterministic compression behavior. // CompressorConfig controls the deterministic compression behavior.
type CompressorConfig struct { type CompressorConfig struct {
ChunkSize int // Messages per chunk node (default 8) ChunkSize int // Messages per chunk node (default 8)
SectionSize int // Chunks per section node (default 4) SectionSize int // Chunks per section node (default 4)
MaxSentences int // Max sentences to extract per message (default 2) MaxSentences int // Max sentences to extract per message (default 2)
TargetRatio float64 // Target compression ratio (default 0.25 = 4:1) TargetRatio float64 // Target compression ratio (default 0.25 = 4:1)
} }
// DefaultCompressorConfig returns sensible defaults. // DefaultCompressorConfig returns sensible defaults.

View file

@ -220,9 +220,9 @@ func TestSelectDAGLevel(t *testing.T) {
func TestTailMessageCount(t *testing.T) { func TestTailMessageCount(t *testing.T) {
assert.Equal(t, 4, TailMessageCount(100)) // Minimum assert.Equal(t, 4, TailMessageCount(100)) // Minimum
assert.Equal(t, 20, TailMessageCount(1000)) // 1000/50 assert.Equal(t, 20, TailMessageCount(1000)) // 1000/50
assert.Equal(t, 4, TailMessageCount(0)) // Zero budget assert.Equal(t, 4, TailMessageCount(0)) // Zero budget
assert.Equal(t, 4, TailMessageCount(-1)) // Negative assert.Equal(t, 4, TailMessageCount(-1)) // Negative
} }
func TestRenderDAGForBudget(t *testing.T) { func TestRenderDAGForBudget(t *testing.T) {

View file

@ -2,7 +2,7 @@ package delegate
import ( import (
"context" "context"
"encoding/json" jsonv2 "github.com/go-json-experiment/json"
"testing" "testing"
"github.com/sipeed/picoclaw/pkg/ids" "github.com/sipeed/picoclaw/pkg/ids"
@ -38,7 +38,7 @@ func TestCronKVBackend_Roundtrip(t *testing.T) {
}, },
} }
data, err := json.Marshal(store) data, err := jsonv2.Marshal(store)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, d.UpsertKV(ctx, agentID, kvKey, string(data))) require.NoError(t, d.UpsertKV(ctx, agentID, kvKey, string(data)))
@ -48,7 +48,7 @@ func TestCronKVBackend_Roundtrip(t *testing.T) {
require.NotEmpty(t, raw) require.NotEmpty(t, raw)
var loaded cronStore var loaded cronStore
require.NoError(t, json.Unmarshal([]byte(raw), &loaded)) require.NoError(t, jsonv2.Unmarshal([]byte(raw), &loaded))
assert.Equal(t, 1, loaded.Version) assert.Equal(t, 1, loaded.Version)
assert.Len(t, loaded.Jobs, 2) assert.Len(t, loaded.Jobs, 2)

View file

@ -5,8 +5,8 @@ package memory
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@ -23,7 +23,7 @@ const migrationMarkerFile = ".sessions_migrated"
type SessionFile struct { type SessionFile struct {
Key string `json:"key"` Key string `json:"key"`
Messages []SessionMsg `json:"messages"` Messages []SessionMsg `json:"messages"`
Summary string `json:"summary,omitempty"` Summary string `json:"summary,omitzero"`
Created time.Time `json:"created"` Created time.Time `json:"created"`
Updated time.Time `json:"updated"` Updated time.Time `json:"updated"`
} }
@ -80,7 +80,7 @@ func MigrateFileSessions(ctx context.Context, del MemoryDelegate, agentID, sessi
} }
var sess SessionFile var sess SessionFile
if err := json.Unmarshal(data, &sess); err != nil { if err := jsonv2.Unmarshal(data, &sess); err != nil {
logger.WarnCF("migrate", "Failed to parse session file", logger.WarnCF("migrate", "Failed to parse session file",
map[string]interface{}{"path": sessPath, "error": err.Error()}) map[string]interface{}{"path": sessPath, "error": err.Error()})
result.Errors++ result.Errors++

View file

@ -2,7 +2,7 @@ package memory
import ( import (
"context" "context"
"encoding/json" jsonv2 "github.com/go-json-experiment/json"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@ -103,7 +103,7 @@ func (m *mockDelegate) CountAuditEntries(_ context.Context, _ string) (int, erro
func writeSessionFile(t *testing.T, dir, name string, sess SessionFile) { func writeSessionFile(t *testing.T, dir, name string, sess SessionFile) {
t.Helper() t.Helper()
data, err := json.Marshal(sess) data, err := jsonv2.Marshal(sess)
if err != nil { if err != nil {
t.Fatalf("marshal session: %v", err) t.Fatalf("marshal session: %v", err)
} }

View file

@ -2,7 +2,7 @@ package memory
import ( import (
"context" "context"
"encoding/json" jsonv2 "github.com/go-json-experiment/json"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
@ -10,8 +10,8 @@ import (
) )
type legacyState struct { type legacyState struct {
LastChannel string `json:"last_channel,omitempty"` LastChannel string `json:"last_channel,omitzero"`
LastChatID string `json:"last_chat_id,omitempty"` LastChatID string `json:"last_chat_id,omitzero"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
} }
@ -39,7 +39,7 @@ func MigrateState(ctx context.Context, workspace string, delegate MemoryDelegate
} }
var s legacyState var s legacyState
if err := json.Unmarshal(data, &s); err != nil { if err := jsonv2.Unmarshal(data, &s); err != nil {
log.Printf("[WARN] migrate_state: failed to parse %s: %v", stateFile, err) log.Printf("[WARN] migrate_state: failed to parse %s: %v", stateFile, err)
return nil return nil
} }

View file

@ -15,8 +15,8 @@ type Manager struct {
observer *Observer observer *Observer
reflector *Reflector reflector *Reflector
mu sync.Mutex mu sync.Mutex
running map[string]bool // sessionKey -> running flag to prevent concurrent runs running map[string]bool // sessionKey -> running flag to prevent concurrent runs
} }
// ManagerConfig bundles configuration for the observation system. // ManagerConfig bundles configuration for the observation system.

View file

@ -14,8 +14,8 @@
package observation package observation
import ( import (
"encoding/json"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"strings" "strings"
"time" "time"
) )
@ -25,8 +25,8 @@ type Priority string
const ( const (
PriorityCritical Priority = "critical" // 🔴 PriorityCritical Priority = "critical" // 🔴
PriorityNotable Priority = "notable" // 🟡 PriorityNotable Priority = "notable" // 🟡
PriorityInformational Priority = "informational" // 🔵 PriorityInformational Priority = "informational" // 🔵
) )
func (p Priority) Emoji() string { func (p Priority) Emoji() string {
@ -97,7 +97,7 @@ func FormatBlock(observations []Observation) string {
// MarshalObservations serializes observations to JSON for KV storage. // MarshalObservations serializes observations to JSON for KV storage.
func MarshalObservations(obs []Observation) (string, error) { func MarshalObservations(obs []Observation) (string, error) {
data, err := json.Marshal(obs) data, err := jsonv2.Marshal(obs)
if err != nil { if err != nil {
return "", fmt.Errorf("marshal observations: %w", err) return "", fmt.Errorf("marshal observations: %w", err)
} }
@ -110,7 +110,7 @@ func UnmarshalObservations(data string) ([]Observation, error) {
return nil, nil return nil, nil
} }
var obs []Observation var obs []Observation
if err := json.Unmarshal([]byte(data), &obs); err != nil { if err := jsonv2.Unmarshal([]byte(data), &obs); err != nil {
return nil, fmt.Errorf("unmarshal observations: %w", err) return nil, fmt.Errorf("unmarshal observations: %w", err)
} }
return obs, nil return obs, nil

View file

@ -238,8 +238,8 @@ func TestParseKeptIndices(t *testing.T) {
{"normal", "KEEP 0\nDROP 1\nKEEP 2", 2}, {"normal", "KEEP 0\nDROP 1\nKEEP 2", 2},
{"all keep", "KEEP 0\nKEEP 1\nKEEP 2", 3}, {"all keep", "KEEP 0\nKEEP 1\nKEEP 2", 3},
{"all drop", "DROP 0\nDROP 1\nDROP 2", 1}, // Fallback keeps critical {"all drop", "DROP 0\nDROP 1\nDROP 2", 1}, // Fallback keeps critical
{"invalid output", "blah blah", 1}, // Fallback keeps critical {"invalid output", "blah blah", 1}, // Fallback keeps critical
{"out of range", "KEEP 99", 1}, // Fallback keeps critical {"out of range", "KEEP 99", 1}, // Fallback keeps critical
} }
for _, tc := range tests { for _, tc := range tests {

View file

@ -1,7 +1,7 @@
package store package store
import ( import (
"encoding/json" jsonv2 "github.com/go-json-experiment/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@ -38,11 +38,12 @@ func TestNewEmbedderFromConfig_OpenAI_NoKey(t *testing.T) {
func TestNewEmbedderFromConfig_OpenAI_FallbackKey(t *testing.T) { func TestNewEmbedderFromConfig_OpenAI_FallbackKey(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{ respData, _ := jsonv2.Marshal(map[string]interface{}{
"data": []map[string]interface{}{ "data": []map[string]interface{}{
{"index": 0, "embedding": []float32{0.1, 0.2, 0.3}}, {"index": 0, "embedding": []float32{0.1, 0.2, 0.3}},
}, },
}) })
w.Write(respData)
})) }))
defer srv.Close() defer srv.Close()
@ -70,9 +71,10 @@ func TestNewEmbedderFromConfig_OpenAI_FallbackKey(t *testing.T) {
func TestNewEmbedderFromConfig_Ollama(t *testing.T) { func TestNewEmbedderFromConfig_Ollama(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{ respData, _ := jsonv2.Marshal(map[string]interface{}{
"embeddings": [][]float32{{0.1, 0.2, 0.3}}, "embeddings": [][]float32{{0.1, 0.2, 0.3}},
}) })
w.Write(respData)
})) }))
defer srv.Close() defer srv.Close()
@ -97,9 +99,10 @@ func TestNewEmbedderFromConfig_Ollama(t *testing.T) {
func TestNewEmbedderFromConfig_Ollama_FallbackBase(t *testing.T) { func TestNewEmbedderFromConfig_Ollama_FallbackBase(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{ respData, _ := jsonv2.Marshal(map[string]interface{}{
"embeddings": [][]float32{{0.5}}, "embeddings": [][]float32{{0.5}},
}) })
w.Write(respData)
})) }))
defer srv.Close() defer srv.Close()
@ -119,9 +122,10 @@ func TestNewEmbedderFromConfig_Ollama_FallbackBase(t *testing.T) {
func TestNewEmbedderFromConfig_CaseInsensitive(t *testing.T) { func TestNewEmbedderFromConfig_CaseInsensitive(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{ respData, _ := jsonv2.Marshal(map[string]interface{}{
"embeddings": [][]float32{{0.1}}, "embeddings": [][]float32{{0.1}},
}) })
w.Write(respData)
})) }))
defer srv.Close() defer srv.Close()

View file

@ -3,8 +3,8 @@ package store
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"io" "io"
"net/http" "net/http"
"time" "time"
@ -93,7 +93,7 @@ func (o *OllamaEmbedder) embedBatch(ctx context.Context, texts []string) ([]memo
} }
func (o *OllamaEmbedder) embedSingle(ctx context.Context, text string) (memory.Embedding, error) { func (o *OllamaEmbedder) embedSingle(ctx context.Context, text string) (memory.Embedding, error) {
body, err := json.Marshal(ollamaEmbedRequest{ body, err := jsonv2.Marshal(ollamaEmbedRequest{
Model: o.model, Model: o.model,
Input: text, Input: text,
}) })
@ -119,7 +119,7 @@ func (o *OllamaEmbedder) embedSingle(ctx context.Context, text string) (memory.E
} }
var result ollamaEmbedResponse var result ollamaEmbedResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := jsonv2.UnmarshalRead(resp.Body, &result); err != nil {
return nil, fmt.Errorf("decode response: %w", err) return nil, fmt.Errorf("decode response: %w", err)
} }

View file

@ -2,7 +2,7 @@ package store
import ( import (
"context" "context"
"encoding/json" jsonv2 "github.com/go-json-experiment/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@ -18,14 +18,15 @@ func TestOllamaEmbedder_Embed(t *testing.T) {
} }
var req ollamaEmbedRequest var req ollamaEmbedRequest
json.NewDecoder(r.Body).Decode(&req) jsonv2.UnmarshalRead(r.Body, &req)
if req.Model != "test-model" { if req.Model != "test-model" {
t.Errorf("expected model 'test-model', got %q", req.Model) t.Errorf("expected model 'test-model', got %q", req.Model)
} }
json.NewEncoder(w).Encode(ollamaEmbedResponse{ respData, _ := jsonv2.Marshal(ollamaEmbedResponse{
Embeddings: [][]float32{{0.1, 0.2, 0.3, 0.4}}, Embeddings: [][]float32{{0.1, 0.2, 0.3, 0.4}},
}) })
w.Write(respData)
})) }))
defer srv.Close() defer srv.Close()
@ -55,9 +56,10 @@ func TestOllamaEmbedder_EmbedBatch(t *testing.T) {
callCount := 0 callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++ callCount++
json.NewEncoder(w).Encode(ollamaEmbedResponse{ respData, _ := jsonv2.Marshal(ollamaEmbedResponse{
Embeddings: [][]float32{{float32(callCount) * 0.1}}, Embeddings: [][]float32{{float32(callCount) * 0.1}},
}) })
w.Write(respData)
})) }))
defer srv.Close() defer srv.Close()
@ -91,7 +93,8 @@ func TestOllamaEmbedder_ServerError(t *testing.T) {
func TestOllamaEmbedder_EmptyResponse(t *testing.T) { func TestOllamaEmbedder_EmptyResponse(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(ollamaEmbedResponse{Embeddings: [][]float32{}}) respData, _ := jsonv2.Marshal(ollamaEmbedResponse{Embeddings: [][]float32{}})
w.Write(respData)
})) }))
defer srv.Close() defer srv.Close()

View file

@ -3,8 +3,8 @@ package store
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"io" "io"
"net/http" "net/http"
"time" "time"
@ -59,7 +59,7 @@ func NewOpenAIEmbedder(cfg OpenAIEmbedderConfig) *OpenAIEmbedder {
type openAIEmbedRequest struct { type openAIEmbedRequest struct {
Input interface{} `json:"input"` // string or []string Input interface{} `json:"input"` // string or []string
Model string `json:"model"` Model string `json:"model"`
EncodingFormat string `json:"encoding_format,omitempty"` EncodingFormat string `json:"encoding_format,omitzero"`
} }
type openAIEmbedResponse struct { type openAIEmbedResponse struct {
@ -100,7 +100,7 @@ func (o *OpenAIEmbedder) call(ctx context.Context, texts []string) ([]memory.Emb
input = texts input = texts
} }
body, err := json.Marshal(openAIEmbedRequest{ body, err := jsonv2.Marshal(openAIEmbedRequest{
Input: input, Input: input,
Model: o.model, Model: o.model,
EncodingFormat: "float", EncodingFormat: "float",
@ -130,7 +130,7 @@ func (o *OpenAIEmbedder) call(ctx context.Context, texts []string) ([]memory.Emb
} }
var result openAIEmbedResponse var result openAIEmbedResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := jsonv2.UnmarshalRead(resp.Body, &result); err != nil {
return nil, fmt.Errorf("decode response: %w", err) return nil, fmt.Errorf("decode response: %w", err)
} }

View file

@ -2,7 +2,7 @@ package store
import ( import (
"context" "context"
"encoding/json" jsonv2 "github.com/go-json-experiment/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@ -20,16 +20,17 @@ func TestOpenAIEmbedder_Embed(t *testing.T) {
} }
var req openAIEmbedRequest var req openAIEmbedRequest
json.NewDecoder(r.Body).Decode(&req) jsonv2.UnmarshalRead(r.Body, &req)
if req.Model != "test-embed" { if req.Model != "test-embed" {
t.Errorf("expected model 'test-embed', got %q", req.Model) t.Errorf("expected model 'test-embed', got %q", req.Model)
} }
json.NewEncoder(w).Encode(openAIEmbedResponse{ respData, _ := jsonv2.Marshal(openAIEmbedResponse{
Data: []openAIEmbedData{ Data: []openAIEmbedData{
{Index: 0, Embedding: []float32{0.5, 0.6, 0.7}}, {Index: 0, Embedding: []float32{0.5, 0.6, 0.7}},
}, },
}) })
w.Write(respData)
})) }))
defer srv.Close() defer srv.Close()
@ -57,7 +58,7 @@ func TestOpenAIEmbedder_Embed(t *testing.T) {
func TestOpenAIEmbedder_EmbedBatch(t *testing.T) { func TestOpenAIEmbedder_EmbedBatch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req openAIEmbedRequest var req openAIEmbedRequest
json.NewDecoder(r.Body).Decode(&req) jsonv2.UnmarshalRead(r.Body, &req)
// Batch request should send array // Batch request should send array
texts, ok := req.Input.([]interface{}) texts, ok := req.Input.([]interface{})
@ -73,7 +74,8 @@ func TestOpenAIEmbedder_EmbedBatch(t *testing.T) {
} }
} }
json.NewEncoder(w).Encode(openAIEmbedResponse{Data: data}) respData, _ := jsonv2.Marshal(openAIEmbedResponse{Data: data})
w.Write(respData)
})) }))
defer srv.Close() defer srv.Close()
@ -94,16 +96,17 @@ func TestOpenAIEmbedder_EmbedBatch(t *testing.T) {
func TestOpenAIEmbedder_SingleTextNotArray(t *testing.T) { func TestOpenAIEmbedder_SingleTextNotArray(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req openAIEmbedRequest var req openAIEmbedRequest
json.NewDecoder(r.Body).Decode(&req) jsonv2.UnmarshalRead(r.Body, &req)
// Single text should be sent as string, not array // Single text should be sent as string, not array
if _, ok := req.Input.(string); !ok { if _, ok := req.Input.(string); !ok {
t.Errorf("expected string input for single text, got %T", req.Input) t.Errorf("expected string input for single text, got %T", req.Input)
} }
json.NewEncoder(w).Encode(openAIEmbedResponse{ respData, _ := jsonv2.Marshal(openAIEmbedResponse{
Data: []openAIEmbedData{{Index: 0, Embedding: []float32{1.0}}}, Data: []openAIEmbedData{{Index: 0, Embedding: []float32{1.0}}},
}) })
w.Write(respData)
})) }))
defer srv.Close() defer srv.Close()
@ -140,9 +143,10 @@ func TestOpenAIEmbedder_NoAuth(t *testing.T) {
if r.Header.Get("Authorization") != "" { if r.Header.Get("Authorization") != "" {
t.Error("expected no auth header when key is empty") t.Error("expected no auth header when key is empty")
} }
json.NewEncoder(w).Encode(openAIEmbedResponse{ respData, _ := jsonv2.Marshal(openAIEmbedResponse{
Data: []openAIEmbedData{{Index: 0, Embedding: []float32{1.0}}}, Data: []openAIEmbedData{{Index: 0, Embedding: []float32{1.0}}},
}) })
w.Write(respData)
})) }))
defer srv.Close() defer srv.Close()

View file

@ -2,8 +2,8 @@ package store
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"strings" "strings"
"github.com/sipeed/picoclaw/pkg/ids" "github.com/sipeed/picoclaw/pkg/ids"
@ -25,31 +25,31 @@ const (
// MemoryToolRequest is the input to the memory tool. // MemoryToolRequest is the input to the memory tool.
type MemoryToolRequest struct { type MemoryToolRequest struct {
Action MemoryToolAction `json:"action"` Action MemoryToolAction `json:"action"`
Query string `json:"query,omitempty"` // For search Query string `json:"query,omitzero"` // For search
ID string `json:"id,omitempty"` // For read/update/delete ID string `json:"id,omitzero"` // For read/update/delete
Content string `json:"content,omitempty"` // For write/update Content string `json:"content,omitzero"` // For write/update
Source string `json:"source,omitempty"` // For write Source string `json:"source,omitzero"` // For write
Sector string `json:"sector,omitempty"` // For write: episodic/semantic/procedural/reflective Sector string `json:"sector,omitzero"` // For write: episodic/semantic/procedural/reflective
Tags string `json:"tags,omitempty"` // For write: comma-separated Tags string `json:"tags,omitzero"` // For write: comma-separated
Tier string `json:"tier,omitempty"` // "recall" or "archival" — defaults to "recall" Tier string `json:"tier,omitzero"` // "recall" or "archival" — defaults to "recall"
Limit int `json:"limit,omitempty"` // For search — defaults to 5 Limit int `json:"limit,omitzero"` // For search — defaults to 5
} }
// MemoryToolResponse is the output of the memory tool. // MemoryToolResponse is the output of the memory tool.
type MemoryToolResponse struct { type MemoryToolResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
Message string `json:"message,omitempty"` Message string `json:"message,omitzero"`
Results []MemoryToolEntry `json:"results,omitempty"` Results []MemoryToolEntry `json:"results,omitzero"`
Status *MemoryToolStatus `json:"status,omitempty"` Status *MemoryToolStatus `json:"status,omitzero"`
} }
// MemoryToolEntry is a single memory entry in tool results. // MemoryToolEntry is a single memory entry in tool results.
type MemoryToolEntry struct { type MemoryToolEntry struct {
ID string `json:"id"` ID string `json:"id"`
Content string `json:"content"` Content string `json:"content"`
Source string `json:"source,omitempty"` Source string `json:"source,omitzero"`
Sector string `json:"sector,omitempty"` Sector string `json:"sector,omitzero"`
Score float64 `json:"score,omitempty"` Score float64 `json:"score,omitzero"`
} }
// MemoryToolStatus summarizes the memory system state. // MemoryToolStatus summarizes the memory system state.
@ -82,7 +82,7 @@ func NewMemoryTool(store *MemoryStore, agentID, session string) *MemoryTool {
// Execute processes a memory tool request and returns a JSON response. // Execute processes a memory tool request and returns a JSON response.
func (t *MemoryTool) Execute(ctx context.Context, input string) (string, error) { func (t *MemoryTool) Execute(ctx context.Context, input string) (string, error) {
var req MemoryToolRequest var req MemoryToolRequest
if err := json.Unmarshal([]byte(input), &req); err != nil { if err := jsonv2.Unmarshal([]byte(input), &req); err != nil {
return t.errorResponse("invalid input: " + err.Error()), nil return t.errorResponse("invalid input: " + err.Error()), nil
} }
@ -325,6 +325,6 @@ func (t *MemoryTool) errorResponse(msg string) string {
} }
func (t *MemoryTool) jsonResponse(resp *MemoryToolResponse) string { func (t *MemoryTool) jsonResponse(resp *MemoryToolResponse) string {
b, _ := json.Marshal(resp) b, _ := jsonv2.Marshal(resp)
return string(b) return string(b)
} }

View file

@ -2,7 +2,7 @@ package store
import ( import (
"context" "context"
"encoding/json" jsonv2 "github.com/go-json-experiment/json"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -22,7 +22,7 @@ func executeAndParse(t *testing.T, tool *MemoryTool, input string) *MemoryToolRe
require.NoError(t, err) require.NoError(t, err)
var resp MemoryToolResponse var resp MemoryToolResponse
require.NoError(t, json.Unmarshal([]byte(raw), &resp)) require.NoError(t, jsonv2.Unmarshal([]byte(raw), &resp))
return &resp return &resp
} }

View file

@ -1,13 +1,14 @@
package migrate package migrate
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"unicode" "unicode"
jsonv2 "github.com/go-json-experiment/json"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
) )
@ -51,7 +52,7 @@ func LoadOpenClawConfig(configPath string) (map[string]interface{}, error) {
} }
var raw map[string]interface{} var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil { if err := jsonv2.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parsing OpenClaw config: %w", err) return nil, fmt.Errorf("parsing OpenClaw config: %w", err)
} }

View file

@ -1,11 +1,12 @@
package migrate package migrate
import ( import (
"encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
jsonv2 "github.com/go-json-experiment/json"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
) )
@ -104,7 +105,7 @@ func TestLoadOpenClawConfig(t *testing.T) {
}, },
} }
data, err := json.Marshal(openclawConfig) data, err := jsonv2.Marshal(openclawConfig)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -583,7 +584,7 @@ func TestRunDryRun(t *testing.T) {
}, },
}, },
} }
data, _ := json.Marshal(configData) data, _ := jsonv2.Marshal(configData)
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644) os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
opts := Options{ opts := Options{
@ -638,7 +639,7 @@ func TestRunFullMigration(t *testing.T) {
}, },
}, },
} }
data, _ := json.Marshal(configData) data, _ := jsonv2.Marshal(configData)
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644) os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
opts := Options{ opts := Options{
@ -784,7 +785,7 @@ func TestRunConfigOnly(t *testing.T) {
}, },
}, },
} }
data, _ := json.Marshal(configData) data, _ := jsonv2.Marshal(configData)
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644) os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
opts := Options{ opts := Options{
@ -824,7 +825,7 @@ func TestRunWorkspaceOnly(t *testing.T) {
}, },
}, },
} }
data, _ := json.Marshal(configData) data, _ := jsonv2.Marshal(configData)
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644) os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
opts := Options{ opts := Options{

View file

@ -1,12 +1,13 @@
package pcerrors package pcerrors
import ( import (
"encoding/json"
"fmt" "fmt"
"io" "io"
"os" "os"
"regexp" "regexp"
"strings" "strings"
jsonv2 "github.com/go-json-experiment/json"
) )
// CLIHandler is the PicoClaw error lifecycle boundary for the CLI. // CLIHandler is the PicoClaw error lifecycle boundary for the CLI.
@ -50,7 +51,7 @@ func (h CLIHandler) Handle(err error) int {
if h.Verbose { if h.Verbose {
record["detail"] = err.Error() record["detail"] = err.Error()
} }
data, _ := json.Marshal(record) data, _ := jsonv2.Marshal(record)
_, _ = fmt.Fprintln(w, string(data)) _, _ = fmt.Fprintln(w, string(data))
} else { } else {
_, _ = fmt.Fprintln(w, msg) _, _ = fmt.Fprintln(w, msg)

View file

@ -2,9 +2,9 @@
package security package security
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"strings" "strings"
) )
@ -50,12 +50,12 @@ func ExtractJSON(text string, dest interface{}, opts *ExtractJSONOptions) error
return ErrNoJSON return ErrNoJSON
} }
dec := json.NewDecoder(strings.NewReader(cleaned)) var jsonOpts []jsonv2.Options
if opts.DisallowUnknownFields { if opts.DisallowUnknownFields {
dec.DisallowUnknownFields() jsonOpts = append(jsonOpts, jsonv2.RejectUnknownMembers(true))
} }
if err := dec.Decode(dest); err != nil { if err := jsonv2.Unmarshal([]byte(cleaned), dest, jsonOpts...); err != nil {
return fmt.Errorf("json decode: %w", err) return fmt.Errorf("json decode: %w", err)
} }
return nil return nil

View file

@ -107,7 +107,7 @@ func TestExtractJSON_InjectionAttempts(t *testing.T) {
&ExtractJSONOptions{DisallowUnknownFields: true}, &ExtractJSONOptions{DisallowUnknownFields: true},
func(t *testing.T, err error) { func(t *testing.T, err error) {
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "unknown field") assert.Contains(t, err.Error(), "unknown object member name")
}, },
}, },
{ {

View file

@ -1,9 +1,10 @@
package security package security
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
@ -132,7 +133,7 @@ func (ss *SecretStore) load() error {
return err return err
} }
var entries []SecretEntry var entries []SecretEntry
if err := json.Unmarshal(data, &entries); err != nil { if err := jsonv2.Unmarshal(data, &entries); err != nil {
return fmt.Errorf("parse secret store: %w", err) return fmt.Errorf("parse secret store: %w", err)
} }
for _, e := range entries { for _, e := range entries {
@ -150,7 +151,7 @@ func (ss *SecretStore) save() error {
} }
ss.mu.RUnlock() ss.mu.RUnlock()
data, err := json.MarshalIndent(entries, "", " ") data, err := jsonv2.Marshal(entries, jsontext.WithIndent(" "))
if err != nil { if err != nil {
return fmt.Errorf("marshal secret store: %w", err) return fmt.Errorf("marshal secret store: %w", err)
} }

View file

@ -2,8 +2,10 @@ package securebus
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"log"
"sync"
"time" "time"
"github.com/sipeed/picoclaw/pkg/itr" "github.com/sipeed/picoclaw/pkg/itr"
@ -62,6 +64,7 @@ type Bus struct {
executor ToolExecutor executor ToolExecutor
toolSearch ToolSearchFunc // nil = no tool search support toolSearch ToolSearchFunc // nil = no tool search support
done chan struct{} done chan struct{}
closeOnce sync.Once
} }
// New creates a Bus and starts background worker goroutines. // New creates a Bus and starts background worker goroutines.
@ -106,10 +109,12 @@ func (b *Bus) AuditLog() *AuditLog {
return b.audit return b.audit
} }
// Close shuts down the bus workers gracefully. // Close shuts down the bus workers gracefully. Safe to call multiple times.
func (b *Bus) Close() { func (b *Bus) Close() {
b.transport.Close() b.closeOnce.Do(func() {
close(b.done) b.transport.Close()
close(b.done)
})
} }
// Execute is a convenience method for in-process callers that don't want to // Execute is a convenience method for in-process callers that don't want to
@ -155,12 +160,16 @@ func (b *Bus) dispatch(ctx context.Context, req itr.ToolRequest) itr.ToolRespons
case itr.CmdToolSearch: case itr.CmdToolSearch:
resp := b.handleToolSearch(ctx, req) resp := b.handleToolSearch(ctx, req)
event.DurationMS = time.Since(start).Milliseconds() event.DurationMS = time.Since(start).Milliseconds()
_ = b.audit.Append(event) if err := b.audit.Append(event); err != nil {
log.Printf("securebus: audit append failed: %v", err)
}
return resp return resp
default: default:
resp := b.handleRLMCommand(ctx, req) resp := b.handleRLMCommand(ctx, req)
event.DurationMS = time.Since(start).Milliseconds() event.DurationMS = time.Since(start).Milliseconds()
_ = b.audit.Append(event) if err := b.audit.Append(event); err != nil {
log.Printf("securebus: audit append failed: %v", err)
}
return resp return resp
} }
@ -168,7 +177,9 @@ func (b *Bus) dispatch(ctx context.Context, req itr.ToolRequest) itr.ToolRespons
if !ok { if !ok {
event.IsError = true event.IsError = true
event.DurationMS = time.Since(start).Milliseconds() event.DurationMS = time.Since(start).Milliseconds()
_ = b.audit.Append(event) if err := b.audit.Append(event); err != nil {
log.Printf("securebus: audit append failed: %v", err)
}
return itr.NewErrorResponse(req.ID, "internal: payload is not ToolExec") return itr.NewErrorResponse(req.ID, "internal: payload is not ToolExec")
} }
event.ToolName = te.ToolName event.ToolName = te.ToolName
@ -184,17 +195,21 @@ func (b *Bus) dispatch(ctx context.Context, req itr.ToolRequest) itr.ToolRespons
event.IsError = true event.IsError = true
event.PolicyViolation = err.Error() event.PolicyViolation = err.Error()
event.DurationMS = time.Since(start).Milliseconds() event.DurationMS = time.Since(start).Milliseconds()
_ = b.audit.Append(event) if err := b.audit.Append(event); err != nil {
log.Printf("securebus: audit append failed: %v", err)
}
return itr.NewErrorResponse(req.ID, "policy violation: "+err.Error()) return itr.NewErrorResponse(req.ID, "policy violation: "+err.Error())
} }
// 3. Deserialise args — always produce a non-nil map for safe injection. // 3. Deserialise args — always produce a non-nil map for safe injection.
args := make(map[string]interface{}) args := make(map[string]interface{})
if te.ArgsJSON != "" && te.ArgsJSON != "null" { if te.ArgsJSON != "" && te.ArgsJSON != "null" {
if err := json.Unmarshal([]byte(te.ArgsJSON), &args); err != nil { if err := jsonv2.Unmarshal([]byte(te.ArgsJSON), &args); err != nil {
event.IsError = true event.IsError = true
event.DurationMS = time.Since(start).Milliseconds() event.DurationMS = time.Since(start).Milliseconds()
_ = b.audit.Append(event) if err := b.audit.Append(event); err != nil {
log.Printf("securebus: audit append failed: %v", err)
}
return itr.NewErrorResponse(req.ID, fmt.Sprintf("invalid args JSON: %v", err)) return itr.NewErrorResponse(req.ID, fmt.Sprintf("invalid args JSON: %v", err))
} }
} }
@ -204,7 +219,9 @@ func (b *Bus) dispatch(ctx context.Context, req itr.ToolRequest) itr.ToolRespons
if err != nil { if err != nil {
event.IsError = true event.IsError = true
event.DurationMS = time.Since(start).Milliseconds() event.DurationMS = time.Since(start).Milliseconds()
_ = b.audit.Append(event) if err := b.audit.Append(event); err != nil {
log.Printf("securebus: audit append failed: %v", err)
}
return itr.NewErrorResponse(req.ID, "secret injection failed: "+err.Error()) return itr.NewErrorResponse(req.ID, "secret injection failed: "+err.Error())
} }
event.SecretsAccessed = injectedSecrets event.SecretsAccessed = injectedSecrets
@ -232,7 +249,9 @@ func (b *Bus) dispatch(ctx context.Context, req itr.ToolRequest) itr.ToolRespons
event.IsError = resp.IsError event.IsError = resp.IsError
event.DurationMS = time.Since(start).Milliseconds() event.DurationMS = time.Since(start).Milliseconds()
_ = b.audit.Append(event) if err := b.audit.Append(event); err != nil {
log.Printf("securebus: audit append failed: %v", err)
}
return resp return resp
} }

View file

@ -2,7 +2,7 @@ package securebus_test
import ( import (
"context" "context"
"encoding/json" jsonv2 "github.com/go-json-experiment/json"
"testing" "testing"
"github.com/sipeed/picoclaw/pkg/itr" "github.com/sipeed/picoclaw/pkg/itr"
@ -49,7 +49,7 @@ func makeArgsJSON(kv map[string]interface{}) string {
if kv == nil { if kv == nil {
return "{}" return "{}"
} }
b, _ := json.Marshal(kv) b, _ := jsonv2.Marshal(kv)
return string(b) return string(b)
} }
@ -248,3 +248,56 @@ func TestBus_RLMFinalCommand(t *testing.T) {
assert.False(t, resp.IsError) assert.False(t, resp.IsError)
assert.Equal(t, "the answer", resp.Result) assert.Equal(t, "the answer", resp.Result)
} }
func TestBus_CloseIdempotent(t *testing.T) {
bus := makeBus(t, nil, nil)
assert.NotPanics(t, func() {
bus.Close()
bus.Close()
bus.Close()
}, "Close() must be safe to call multiple times")
}
func TestBus_ToolSearch(t *testing.T) {
bus := makeBus(t, nil, nil)
defer bus.Close()
bus.SetToolSearch(func(query string, maxResults int) string {
return `[{"name":"read_file","description":"reads a file"}]`
})
req := itr.NewToolSearchRequest("req-search", "sess", "file operations", 5)
resp := bus.Execute(context.Background(), req)
assert.False(t, resp.IsError)
assert.Contains(t, resp.Result, "read_file")
}
func TestBus_ToolSearchNotConfigured(t *testing.T) {
bus := makeBus(t, nil, nil)
defer bus.Close()
req := itr.NewToolSearchRequest("req-search2", "sess", "anything", 5)
resp := bus.Execute(context.Background(), req)
assert.True(t, resp.IsError)
assert.Contains(t, resp.Result, "not configured")
}
func TestBus_NilToolResult(t *testing.T) {
executor := func(ctx context.Context, name string, args map[string]interface{}) *tools.ToolResult {
return nil
}
capLookup := func(name string) (tools.ToolCapabilities, bool) {
return tools.ZeroCapabilities(), true
}
bus := securebus.New(securebus.DefaultBusConfig(), nil, capLookup, executor)
defer bus.Close()
req := itr.NewToolExecRequest("req-nil", "sess", "tc", "something", "{}")
resp := bus.Execute(context.Background(), req)
assert.False(t, resp.IsError)
assert.Empty(t, resp.Result)
}

View file

@ -2,13 +2,15 @@ package session
import ( import (
"context" "context"
"encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
"time" "time"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"github.com/sipeed/picoclaw/pkg/cache" "github.com/sipeed/picoclaw/pkg/cache"
"github.com/sipeed/picoclaw/pkg/ids" "github.com/sipeed/picoclaw/pkg/ids"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
@ -19,7 +21,7 @@ import (
type Session struct { type Session struct {
Key string `json:"key"` Key string `json:"key"`
Messages []messages.Message `json:"messages"` Messages []messages.Message `json:"messages"`
Summary string `json:"summary,omitempty"` Summary string `json:"summary,omitzero"`
Created time.Time `json:"created"` Created time.Time `json:"created"`
Updated time.Time `json:"updated"` Updated time.Time `json:"updated"`
} }
@ -197,7 +199,7 @@ func (sm *SessionManager) loadSessionFromDisk(key string) *Session {
return nil return nil
} }
var session Session var session Session
if err := json.Unmarshal(data, &session); err != nil { if err := jsonv2.Unmarshal(data, &session); err != nil {
return nil return nil
} }
return &session return &session
@ -487,7 +489,7 @@ func snapshotSession(s *Session) Session {
} }
func (sm *SessionManager) writeSessionToDisk(key string, session *Session) error { func (sm *SessionManager) writeSessionToDisk(key string, session *Session) error {
data, err := json.MarshalIndent(session, "", " ") data, err := jsonv2.Marshal(session, jsontext.WithIndent(" "))
if err != nil { if err != nil {
return err return err
} }
@ -558,7 +560,7 @@ func (sm *SessionManager) loadSessions() error {
} }
var session Session var session Session
if err := json.Unmarshal(data, &session); err != nil { if err := jsonv2.Unmarshal(data, &session); err != nil {
continue continue
} }

View file

@ -2,7 +2,6 @@ package skills
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -10,6 +9,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
jsonv2 "github.com/go-json-experiment/json"
) )
type SkillInstaller struct { type SkillInstaller struct {
@ -117,7 +118,7 @@ func (si *SkillInstaller) ListAvailableSkills(ctx context.Context) ([]AvailableS
} }
var skills []AvailableSkill var skills []AvailableSkill
if err := json.Unmarshal(body, &skills); err != nil { if err := jsonv2.Unmarshal(body, &skills); err != nil {
return nil, fmt.Errorf("failed to parse skills list: %w", err) return nil, fmt.Errorf("failed to parse skills list: %w", err)
} }

View file

@ -1,7 +1,6 @@
package skills package skills
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
@ -9,6 +8,8 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
jsonv2 "github.com/go-json-experiment/json"
) )
var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`) var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
@ -21,10 +22,10 @@ const (
type SkillMetadata struct { type SkillMetadata struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitzero"`
Links []string `json:"links,omitempty"` Links []string `json:"links,omitzero"`
Domain string `json:"domain,omitempty"` Domain string `json:"domain,omitzero"`
IsMOC bool `json:"is_moc,omitempty"` IsMOC bool `json:"is_moc,omitzero"`
} }
type SkillInfo struct { type SkillInfo struct {
@ -32,8 +33,8 @@ type SkillInfo struct {
Path string `json:"path"` Path string `json:"path"`
Source string `json:"source"` Source string `json:"source"`
Description string `json:"description"` Description string `json:"description"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitzero"`
Domain string `json:"domain,omitempty"` Domain string `json:"domain,omitzero"`
} }
func (info SkillInfo) validate() error { func (info SkillInfo) validate() error {
@ -124,18 +125,18 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo {
continue continue
} }
info := SkillInfo{ info := SkillInfo{
Name: dir.Name(), Name: dir.Name(),
Path: skillFile, Path: skillFile,
Source: "global", Source: "global",
} }
metadata := sl.getSkillMetadata(skillFile) metadata := sl.getSkillMetadata(skillFile)
if metadata != nil { if metadata != nil {
info.Description = metadata.Description info.Description = metadata.Description
info.Name = metadata.Name info.Name = metadata.Name
info.Tags = metadata.Tags info.Tags = metadata.Tags
info.Domain = metadata.Domain info.Domain = metadata.Domain
} }
if err := info.validate(); err != nil { if err := info.validate(); err != nil {
slog.Warn("invalid skill from global", "name", info.Name, "error", err) slog.Warn("invalid skill from global", "name", info.Name, "error", err)
continue continue
@ -165,18 +166,18 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo {
continue continue
} }
info := SkillInfo{ info := SkillInfo{
Name: dir.Name(), Name: dir.Name(),
Path: skillFile, Path: skillFile,
Source: "builtin", Source: "builtin",
} }
metadata := sl.getSkillMetadata(skillFile) metadata := sl.getSkillMetadata(skillFile)
if metadata != nil { if metadata != nil {
info.Description = metadata.Description info.Description = metadata.Description
info.Name = metadata.Name info.Name = metadata.Name
info.Tags = metadata.Tags info.Tags = metadata.Tags
info.Domain = metadata.Domain info.Domain = metadata.Domain
} }
if err := info.validate(); err != nil { if err := info.validate(); err != nil {
slog.Warn("invalid skill from builtin", "name", info.Name, "error", err) slog.Warn("invalid skill from builtin", "name", info.Name, "error", err)
continue continue
@ -281,7 +282,7 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
// Try JSON first (for backward compatibility) // Try JSON first (for backward compatibility)
var jsonMeta SkillMetadata var jsonMeta SkillMetadata
if err := json.Unmarshal([]byte(frontmatter), &jsonMeta); err == nil && jsonMeta.Name != "" { if err := jsonv2.Unmarshal([]byte(frontmatter), &jsonMeta); err == nil && jsonMeta.Name != "" {
return &jsonMeta return &jsonMeta
} }

View file

@ -2,7 +2,6 @@ package state
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"log" "log"
"os" "os"
@ -10,6 +9,9 @@ import (
"sync" "sync"
"time" "time"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"github.com/sipeed/picoclaw/pkg/memory" "github.com/sipeed/picoclaw/pkg/memory"
) )
@ -19,10 +21,10 @@ const kvAgentID = "picoclaw"
// It includes information about the last active channel/chat. // It includes information about the last active channel/chat.
type State struct { type State struct {
// LastChannel is the last channel used for communication // LastChannel is the last channel used for communication
LastChannel string `json:"last_channel,omitempty"` LastChannel string `json:"last_channel,omitzero"`
// LastChatID is the last chat ID used for communication // LastChatID is the last chat ID used for communication
LastChatID string `json:"last_chat_id,omitempty"` LastChatID string `json:"last_chat_id,omitzero"`
// Timestamp is the last time this state was updated // Timestamp is the last time this state was updated
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
@ -73,7 +75,7 @@ func NewManager(workspace string, opts ...Option) *Manager {
if _, err := os.Stat(stateFile); os.IsNotExist(err) { if _, err := os.Stat(stateFile); os.IsNotExist(err) {
if data, err := os.ReadFile(oldStateFile); err == nil { if data, err := os.ReadFile(oldStateFile); err == nil {
if err := json.Unmarshal(data, sm.state); err == nil { if err := jsonv2.Unmarshal(data, sm.state); err == nil {
sm.saveAtomic() sm.saveAtomic()
log.Printf("[INFO] state: migrated state from %s to %s", oldStateFile, stateFile) log.Printf("[INFO] state: migrated state from %s to %s", oldStateFile, stateFile)
} }
@ -180,7 +182,7 @@ func (sm *Manager) saveAtomic() error {
tempFile := sm.stateFile + ".tmp" tempFile := sm.stateFile + ".tmp"
// Marshal state to JSON // Marshal state to JSON
data, err := json.MarshalIndent(sm.state, "", " ") data, err := jsonv2.Marshal(sm.state, jsontext.WithIndent(" "))
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal state: %w", err) return fmt.Errorf("failed to marshal state: %w", err)
} }
@ -211,7 +213,7 @@ func (sm *Manager) load() error {
return fmt.Errorf("failed to read state file: %w", err) return fmt.Errorf("failed to read state file: %w", err)
} }
if err := json.Unmarshal(data, sm.state); err != nil { if err := jsonv2.Unmarshal(data, sm.state); err != nil {
return fmt.Errorf("failed to unmarshal state: %w", err) return fmt.Errorf("failed to unmarshal state: %w", err)
} }

View file

@ -1,11 +1,12 @@
package state package state
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
jsonv2 "github.com/go-json-experiment/json"
) )
func TestAtomicSave(t *testing.T) { func TestAtomicSave(t *testing.T) {
@ -162,7 +163,7 @@ func TestConcurrentAccess(t *testing.T) {
} }
var state State var state State
if err := json.Unmarshal(data, &state); err != nil { if err := jsonv2.Unmarshal(data, &state); err != nil {
t.Errorf("State file contains invalid JSON: %v", err) t.Errorf("State file contains invalid JSON: %v", err)
} }
} }

View file

@ -2,9 +2,10 @@ package tools
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
jsonv2 "github.com/go-json-experiment/json"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
) )
@ -84,7 +85,7 @@ func (t *ToolCallTool) Execute(ctx context.Context, args map[string]interface{})
if len(v) > maxArgsJSON { if len(v) > maxArgsJSON {
return ErrorResult(fmt.Sprintf("arguments JSON too large: %d bytes (max %d)", len(v), maxArgsJSON)) return ErrorResult(fmt.Sprintf("arguments JSON too large: %d bytes (max %d)", len(v), maxArgsJSON))
} }
if err := json.Unmarshal([]byte(v), &toolArgs); err != nil { if err := jsonv2.Unmarshal([]byte(v), &toolArgs); err != nil {
return ErrorResult(fmt.Sprintf("invalid arguments JSON: %v", err)) return ErrorResult(fmt.Sprintf("invalid arguments JSON: %v", err))
} }
case nil: case nil:

View file

@ -2,11 +2,12 @@ package tools
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"strings" "strings"
"time" "time"
jsonv2 "github.com/go-json-experiment/json"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/messages" "github.com/sipeed/picoclaw/pkg/messages"
"github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/session"
@ -109,7 +110,7 @@ func (t *StartFocusTool) Execute(ctx context.Context, args map[string]interface{
StartedAt: time.Now(), StartedAt: time.Now(),
} }
data, err := json.Marshal(state) data, err := jsonv2.Marshal(state)
if err != nil { if err != nil {
return ErrorResult(fmt.Sprintf("marshal focus state: %v", err)) return ErrorResult(fmt.Sprintf("marshal focus state: %v", err))
} }
@ -181,7 +182,7 @@ func (t *CompleteFocusTool) Execute(ctx context.Context, args map[string]interfa
} }
var state FocusState var state FocusState
if err := json.Unmarshal([]byte(raw), &state); err != nil { if err := jsonv2.Unmarshal([]byte(raw), &state); err != nil {
return ErrorResult(fmt.Sprintf("corrupt focus state: %v", err)) return ErrorResult(fmt.Sprintf("corrupt focus state: %v", err))
} }
@ -251,7 +252,7 @@ func (t *CompleteFocusTool) appendKnowledge(ctx context.Context, sessionKey, top
kb := &KnowledgeBlock{} kb := &KnowledgeBlock{}
raw, err := t.delegate.GetKV(ctx, focusAgentID, kvKey) raw, err := t.delegate.GetKV(ctx, focusAgentID, kvKey)
if err == nil && raw != "" { if err == nil && raw != "" {
_ = json.Unmarshal([]byte(raw), kb) _ = jsonv2.Unmarshal([]byte(raw), kb)
} }
kb.Entries = append(kb.Entries, KnowledgeEntry{ kb.Entries = append(kb.Entries, KnowledgeEntry{
@ -260,7 +261,7 @@ func (t *CompleteFocusTool) appendKnowledge(ctx context.Context, sessionKey, top
CreatedAt: time.Now(), CreatedAt: time.Now(),
}) })
data, err := json.Marshal(kb) data, err := jsonv2.Marshal(kb)
if err != nil { if err != nil {
return err return err
} }
@ -280,7 +281,7 @@ func LoadKnowledgeBlock(ctx context.Context, delegate KVStore, sessionKey string
} }
var kb KnowledgeBlock var kb KnowledgeBlock
if err := json.Unmarshal([]byte(raw), &kb); err != nil { if err := jsonv2.Unmarshal([]byte(raw), &kb); err != nil {
return "" return ""
} }

View file

@ -2,9 +2,10 @@ package tools
import ( import (
"context" "context"
"encoding/json"
"testing" "testing"
jsonv2 "github.com/go-json-experiment/json"
"github.com/sipeed/picoclaw/pkg/messages" "github.com/sipeed/picoclaw/pkg/messages"
"github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/session"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -75,7 +76,7 @@ func TestStartFocus(t *testing.T) {
require.NotEmpty(t, raw) require.NotEmpty(t, raw)
var state FocusState var state FocusState
require.NoError(t, json.Unmarshal([]byte(raw), &state)) require.NoError(t, jsonv2.Unmarshal([]byte(raw), &state))
assert.Equal(t, "investigate auth bug", state.Topic) assert.Equal(t, "investigate auth bug", state.Topic)
assert.Equal(t, 2, state.CheckpointIndex) assert.Equal(t, 2, state.CheckpointIndex)
} }
@ -134,7 +135,7 @@ func TestCompleteFocus(t *testing.T) {
require.NotEmpty(t, knowledgeRaw) require.NotEmpty(t, knowledgeRaw)
var kb KnowledgeBlock var kb KnowledgeBlock
require.NoError(t, json.Unmarshal([]byte(knowledgeRaw), &kb)) require.NoError(t, jsonv2.Unmarshal([]byte(knowledgeRaw), &kb))
require.Len(t, kb.Entries, 1) require.Len(t, kb.Entries, 1)
assert.Equal(t, "debug auth", kb.Entries[0].Topic) assert.Equal(t, "debug auth", kb.Entries[0].Topic)
assert.Contains(t, kb.Entries[0].Summary, "token validation") assert.Contains(t, kb.Entries[0].Summary, "token validation")
@ -184,7 +185,7 @@ func TestCompleteFocus_MultipleKnowledgeEntries(t *testing.T) {
knowledgeRaw, _ := delegate.GetKV(ctx, focusAgentID, knowledgeKVPrefix+sk) knowledgeRaw, _ := delegate.GetKV(ctx, focusAgentID, knowledgeKVPrefix+sk)
var kb KnowledgeBlock var kb KnowledgeBlock
require.NoError(t, json.Unmarshal([]byte(knowledgeRaw), &kb)) require.NoError(t, jsonv2.Unmarshal([]byte(knowledgeRaw), &kb))
require.Len(t, kb.Entries, 2) require.Len(t, kb.Entries, 2)
assert.Equal(t, "topic A", kb.Entries[0].Topic) assert.Equal(t, "topic A", kb.Entries[0].Topic)
assert.Equal(t, "topic B", kb.Entries[1].Topic) assert.Equal(t, "topic B", kb.Entries[1].Topic)
@ -286,7 +287,7 @@ func TestLoadKnowledgeBlock(t *testing.T) {
{Topic: "Test", Summary: "Test summary"}, {Topic: "Test", Summary: "Test summary"},
}, },
} }
data, _ := json.Marshal(kb) data, _ := jsonv2.Marshal(kb)
_ = delegate.UpsertKV(ctx, focusAgentID, knowledgeKVPrefix+"test-session", string(data)) _ = delegate.UpsertKV(ctx, focusAgentID, knowledgeKVPrefix+"test-session", string(data))
block = LoadKnowledgeBlock(ctx, delegate, "test-session") block = LoadKnowledgeBlock(ctx, delegate, "test-session")

View file

@ -2,11 +2,13 @@ package tools
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime" "runtime"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
) )
// I2CTool provides I2C bus interaction for reading sensors and controlling peripherals. // I2CTool provides I2C bus interaction for reading sensors and controlling peripherals.
@ -111,7 +113,7 @@ func (t *I2CTool) detect() *ToolResult {
} }
} }
result, _ := json.MarshalIndent(buses, "", " ") result, _ := jsonv2.Marshal(buses, jsontext.WithIndent(" "))
return SilentResult(fmt.Sprintf("Found %d I2C bus(es):\n%s", len(buses), string(result))) return SilentResult(fmt.Sprintf("Found %d I2C bus(es):\n%s", len(buses), string(result)))
} }

View file

@ -1,10 +1,12 @@
package tools package tools
import ( import (
"encoding/json"
"fmt" "fmt"
"syscall" "syscall"
"unsafe" "unsafe"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
) )
// I2C ioctl constants from Linux kernel headers (<linux/i2c-dev.h>, <linux/i2c.h>) // I2C ioctl constants from Linux kernel headers (<linux/i2c-dev.h>, <linux/i2c.h>)
@ -104,7 +106,7 @@ func (t *I2CTool) scan(args map[string]interface{}) *ToolResult {
type deviceEntry struct { type deviceEntry struct {
Address string `json:"address"` Address string `json:"address"`
Status string `json:"status,omitempty"` Status string `json:"status,omitzero"`
} }
var found []deviceEntry var found []deviceEntry
@ -133,11 +135,11 @@ func (t *I2CTool) scan(args map[string]interface{}) *ToolResult {
return SilentResult(fmt.Sprintf("No devices found on %s. Check wiring and pull-up resistors.", devPath)) return SilentResult(fmt.Sprintf("No devices found on %s. Check wiring and pull-up resistors.", devPath))
} }
result, _ := json.MarshalIndent(map[string]interface{}{ result, _ := jsonv2.Marshal(map[string]interface{}{
"bus": devPath, "bus": devPath,
"devices": found, "devices": found,
"count": len(found), "count": len(found),
}, "", " ") }, jsontext.WithIndent(" "))
return SilentResult(fmt.Sprintf("Scan of %s:\n%s", devPath, string(result))) return SilentResult(fmt.Sprintf("Scan of %s:\n%s", devPath, string(result)))
} }
@ -201,13 +203,13 @@ func (t *I2CTool) readDevice(args map[string]interface{}) *ToolResult {
intBytes[i] = int(buf[i]) intBytes[i] = int(buf[i])
} }
result, _ := json.MarshalIndent(map[string]interface{}{ result, _ := jsonv2.Marshal(map[string]interface{}{
"bus": devPath, "bus": devPath,
"address": fmt.Sprintf("0x%02x", addr), "address": fmt.Sprintf("0x%02x", addr),
"bytes": intBytes, "bytes": intBytes,
"hex": hexBytes, "hex": hexBytes,
"length": n, "length": n,
}, "", " ") }, jsontext.WithIndent(" "))
return SilentResult(string(result)) return SilentResult(string(result))
} }

View file

@ -1,6 +1,6 @@
package tools package tools
import "encoding/json" import jsonv2 "github.com/go-json-experiment/json"
// ToolResult represents the structured return value from tool execution. // ToolResult represents the structured return value from tool execution.
// It provides clear semantics for different types of results and supports // It provides clear semantics for different types of results and supports
@ -124,7 +124,7 @@ func UserResult(content string) *ToolResult {
// The Err field is excluded from JSON output via the json:"-" tag. // The Err field is excluded from JSON output via the json:"-" tag.
func (tr *ToolResult) MarshalJSON() ([]byte, error) { func (tr *ToolResult) MarshalJSON() ([]byte, error) {
type Alias ToolResult type Alias ToolResult
return json.Marshal(&struct { return jsonv2.Marshal(&struct {
*Alias *Alias
}{ }{
Alias: (*Alias)(tr), Alias: (*Alias)(tr),

View file

@ -1,9 +1,10 @@
package tools package tools
import ( import (
"encoding/json"
"errors" "errors"
"testing" "testing"
jsonv2 "github.com/go-json-experiment/json"
) )
func TestNewToolResult(t *testing.T) { func TestNewToolResult(t *testing.T) {
@ -125,14 +126,14 @@ func TestToolResultJSONSerialization(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Marshal to JSON // Marshal to JSON
data, err := json.Marshal(tt.result) data, err := jsonv2.Marshal(tt.result)
if err != nil { if err != nil {
t.Fatalf("Failed to marshal: %v", err) t.Fatalf("Failed to marshal: %v", err)
} }
// Unmarshal back // Unmarshal back
var decoded ToolResult var decoded ToolResult
if err := json.Unmarshal(data, &decoded); err != nil { if err := jsonv2.Unmarshal(data, &decoded); err != nil {
t.Fatalf("Failed to unmarshal: %v", err) t.Fatalf("Failed to unmarshal: %v", err)
} }
@ -168,13 +169,13 @@ func TestToolResultWithErrors(t *testing.T) {
} }
// Verify Err is not serialized // Verify Err is not serialized
data, marshalErr := json.Marshal(result) data, marshalErr := jsonv2.Marshal(result)
if marshalErr != nil { if marshalErr != nil {
t.Fatalf("Failed to marshal: %v", marshalErr) t.Fatalf("Failed to marshal: %v", marshalErr)
} }
var decoded ToolResult var decoded ToolResult
if unmarshalErr := json.Unmarshal(data, &decoded); unmarshalErr != nil { if unmarshalErr := jsonv2.Unmarshal(data, &decoded); unmarshalErr != nil {
t.Fatalf("Failed to unmarshal: %v", unmarshalErr) t.Fatalf("Failed to unmarshal: %v", unmarshalErr)
} }
@ -186,14 +187,14 @@ func TestToolResultWithErrors(t *testing.T) {
func TestToolResultJSONStructure(t *testing.T) { func TestToolResultJSONStructure(t *testing.T) {
result := UserResult("test content") result := UserResult("test content")
data, err := json.Marshal(result) data, err := jsonv2.Marshal(result)
if err != nil { if err != nil {
t.Fatalf("Failed to marshal: %v", err) t.Fatalf("Failed to marshal: %v", err)
} }
// Verify JSON structure // Verify JSON structure
var parsed map[string]interface{} var parsed map[string]interface{}
if err := json.Unmarshal(data, &parsed); err != nil { if err := jsonv2.Unmarshal(data, &parsed); err != nil {
t.Fatalf("Failed to parse JSON: %v", err) t.Fatalf("Failed to parse JSON: %v", err)
} }

Some files were not shown because too many files have changed in this diff Show more