diff --git a/internal/fantasy/agent.go b/internal/fantasy/agent.go index f51084ef0..d985470a1 100644 --- a/internal/fantasy/agent.go +++ b/internal/fantasy/agent.go @@ -3,9 +3,9 @@ package fantasy import ( "cmp" "context" - "encoding/json" "errors" "fmt" + jsonv2 "github.com/go-json-experiment/json" "maps" "slices" "sync" @@ -1052,7 +1052,7 @@ func (a *agent) validateToolCall(toolCall ToolCallContent, availableTools []Agen } 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) } diff --git a/internal/fantasy/agent_stream_test.go b/internal/fantasy/agent_stream_test.go index ea9009305..f7fbd6cce 100644 --- a/internal/fantasy/agent_stream_test.go +++ b/internal/fantasy/agent_stream_test.go @@ -2,8 +2,8 @@ package fantasy import ( "context" - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" "testing" "github.com/stretchr/testify/require" @@ -43,7 +43,7 @@ func (e *EchoTool) Run(ctx context.Context, params ToolCall) (ToolResponse, erro 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 } diff --git a/internal/fantasy/agent_test.go b/internal/fantasy/agent_test.go index 33c137a13..70ced785d 100644 --- a/internal/fantasy/agent_test.go +++ b/internal/fantasy/agent_test.go @@ -2,11 +2,12 @@ package fantasy import ( "context" - "encoding/json" "errors" "fmt" "testing" + jsonv2 "github.com/go-json-experiment/json" + "github.com/stretchr/testify/require" ) @@ -300,7 +301,7 @@ func TestAgent_Generate_ResultToolCalls(t *testing.T) { // Parse and verify input 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.Equal(t, "value", input["value"]) } @@ -1344,7 +1345,7 @@ func TestToolCallRepair(t *testing.T) { toolCalls := result.Steps[0].Content.ToolCalls() require.Len(t, toolCalls, 1) 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) { @@ -1449,7 +1450,7 @@ func TestToolCallRepair(t *testing.T) { toolCalls := result.Steps[0].Content.ToolCalls() require.Len(t, toolCalls, 1) 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) { @@ -1762,7 +1763,7 @@ func TestAgent_MediaToolResponses(t *testing.T) { require.NotEmpty(t, toolResults[0].ClientMetadata) var metadata ImageMetadata - err = json.Unmarshal([]byte(toolResults[0].ClientMetadata), &metadata) + err = jsonv2.Unmarshal([]byte(toolResults[0].ClientMetadata), &metadata) require.NoError(t, err) require.Equal(t, 800, metadata.Width) require.Equal(t, 600, metadata.Height) diff --git a/internal/fantasy/content.go b/internal/fantasy/content.go index 0edd8410e..d5b38a564 100644 --- a/internal/fantasy/content.go +++ b/internal/fantasy/content.go @@ -1,10 +1,10 @@ package fantasy -import "encoding/json" +// No imports needed — json method signatures are defined inline. // ProviderOptionsData is an interface for provider-specific options data. -// All implementations MUST also implement encoding/json.Marshaler and -// encoding/json.Unmarshaler interfaces to ensure proper JSON serialization +// All implementations MUST also implement jsonv2.MarshalerV2 and +// jsonv2.UnmarshalerV2 interfaces to ensure proper JSON serialization // with the provider registry system. // // Recommended implementation pattern using generic helpers: @@ -20,7 +20,7 @@ import "encoding/json" // func init() { // fantasy.RegisterProviderType(TypeMyProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) { // var opts MyProviderOptions -// if err := json.Unmarshal(data, &opts); err != nil { +// if err := jsonv2.Unmarshal(data, &opts); err != nil { // return nil, err // } // return &opts, nil @@ -30,28 +30,27 @@ import "encoding/json" // // Implement ProviderOptionsData interface // func (*MyProviderOptions) Options() {} // -// // Implement json.Marshaler using the generic helper -// func (m MyProviderOptions) MarshalJSON() ([]byte, error) { +// // Implement jsonv2.MarshalerTo using the generic helper +// func (m MyProviderOptions) MarshalJSONTo(enc *jsontext.Encoder) error { // 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. -// func (m *MyProviderOptions) UnmarshalJSON(data []byte) error { +// func (m *MyProviderOptions) UnmarshalJSONFrom(dec *jsontext.Decoder) error { // type plain MyProviderOptions // var p plain -// if err := fantasy.UnmarshalProviderType(data, &p); err != nil { +// if err := fantasy.UnmarshalProviderTypeFrom(dec, &p); err != nil { // return err // } // *m = MyProviderOptions(p) // return nil // } type ProviderOptionsData interface { - // Options is a marker method that identifies types implementing this interface. Options() - json.Marshaler - json.Unmarshaler + MarshalJSON() ([]byte, error) + UnmarshalJSON([]byte) error } // ProviderMetadata represents additional provider-specific metadata. @@ -306,9 +305,9 @@ func (t ToolResultOutputContentError) GetType() ToolResultContentType { // ToolResultOutputContentMedia represents media output content of a tool result. type ToolResultOutputContentMedia struct { - Data string `json:"data"` // for media type (base64) - MediaType string `json:"media_type"` // for media type - Text string `json:"text,omitempty"` // optional text content accompanying the media + Data string `json:"data"` // for media type (base64) + MediaType string `json:"media_type"` // for media type + Text string `json:"text,omitzero"` // optional text content accompanying the 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. ProviderMetadata ProviderMetadata `json:"provider_metadata"` // 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) - ValidationError error `json:"validation_error,omitempty"` + ValidationError error `json:"validation_error,omitzero"` } // GetType returns the type of the tool call content. diff --git a/internal/fantasy/content_json.go b/internal/fantasy/content_json.go index 1f2d53ed0..ac100500d 100644 --- a/internal/fantasy/content_json.go +++ b/internal/fantasy/content_json.go @@ -1,38 +1,39 @@ package fantasy import ( - "encoding/json" "errors" "fmt" + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" ) // contentJSON is a helper type for JSON serialization of Content in Response. type contentJSON struct { - Type string `json:"type"` - Data json.RawMessage `json:"data"` + Type string `json:"type"` + Data jsontext.Value `json:"data"` } // messagePartJSON is a helper type for JSON serialization of MessagePart. type messagePartJSON struct { - Type string `json:"type"` - Data json.RawMessage `json:"data"` + Type string `json:"type"` + Data jsontext.Value `json:"data"` } // toolResultOutputJSON is a helper type for JSON serialization of ToolResultOutputContent. type toolResultOutputJSON struct { - Type string `json:"type"` - Data json.RawMessage `json:"data"` + Type string `json:"type"` + Data jsontext.Value `json:"data"` } // toolJSON is a helper type for JSON serialization of Tool. type toolJSON struct { - Type string `json:"type"` - Data json.RawMessage `json:"data"` + Type string `json:"type"` + Data jsontext.Value `json:"data"` } // MarshalJSON implements json.Marshaler for TextContent. func (t TextContent) MarshalJSON() ([]byte, error) { - dataBytes, err := json.Marshal(struct { + dataBytes, err := jsonv2.Marshal(struct { Text string `json:"text"` ProviderMetadata ProviderMetadata `json:"provider_metadata,omitempty"` }{ @@ -43,25 +44,25 @@ func (t TextContent) MarshalJSON() ([]byte, error) { return nil, err } - return json.Marshal(contentJSON{ + return jsonv2.Marshal(contentJSON{ Type: string(ContentTypeText), - Data: json.RawMessage(dataBytes), + Data: jsontext.Value(dataBytes), }) } // UnmarshalJSON implements json.Unmarshaler for TextContent. func (t *TextContent) UnmarshalJSON(data []byte) error { var cj contentJSON - if err := json.Unmarshal(data, &cj); err != nil { + if err := jsonv2.Unmarshal(data, &cj); err != nil { return err } var aux struct { - Text string `json:"text"` - ProviderMetadata map[string]json.RawMessage `json:"provider_metadata,omitempty"` + Text string `json:"text"` + ProviderMetadata map[string]jsontext.Value `json:"provider_metadata,omitempty"` } - if err := json.Unmarshal(cj.Data, &aux); err != nil { + if err := jsonv2.Unmarshal(cj.Data, &aux); err != nil { return err } @@ -80,7 +81,7 @@ func (t *TextContent) UnmarshalJSON(data []byte) error { // MarshalJSON implements json.Marshaler for ReasoningContent. func (r ReasoningContent) MarshalJSON() ([]byte, error) { - dataBytes, err := json.Marshal(struct { + dataBytes, err := jsonv2.Marshal(struct { Text string `json:"text"` ProviderMetadata ProviderMetadata `json:"provider_metadata,omitempty"` }{ @@ -91,25 +92,25 @@ func (r ReasoningContent) MarshalJSON() ([]byte, error) { return nil, err } - return json.Marshal(contentJSON{ + return jsonv2.Marshal(contentJSON{ Type: string(ContentTypeReasoning), - Data: json.RawMessage(dataBytes), + Data: jsontext.Value(dataBytes), }) } // UnmarshalJSON implements json.Unmarshaler for ReasoningContent. func (r *ReasoningContent) UnmarshalJSON(data []byte) error { var cj contentJSON - if err := json.Unmarshal(data, &cj); err != nil { + if err := jsonv2.Unmarshal(data, &cj); err != nil { return err } var aux struct { - Text string `json:"text"` - ProviderMetadata map[string]json.RawMessage `json:"provider_metadata,omitempty"` + Text string `json:"text"` + ProviderMetadata map[string]jsontext.Value `json:"provider_metadata,omitempty"` } - if err := json.Unmarshal(cj.Data, &aux); err != nil { + if err := jsonv2.Unmarshal(cj.Data, &aux); err != nil { return err } @@ -126,41 +127,41 @@ func (r *ReasoningContent) UnmarshalJSON(data []byte) error { return nil } -// MarshalJSON implements json.Marshaler for FileContent. -func (f FileContent) MarshalJSON() ([]byte, error) { - dataBytes, err := json.Marshal(struct { +// MarshalJSONV2 implements jsonv2.MarshalerTo for FileContent. +func (f FileContent) MarshalJSONTo(enc *jsontext.Encoder) error { + dataBytes, err := jsonv2.Marshal(struct { MediaType string `json:"media_type"` Data []byte `json:"data"` - ProviderMetadata ProviderMetadata `json:"provider_metadata,omitempty"` + ProviderMetadata ProviderMetadata `json:"provider_metadata,omitzero"` }{ MediaType: f.MediaType, Data: f.Data, ProviderMetadata: f.ProviderMetadata, }) if err != nil { - return nil, err + return err } - return json.Marshal(contentJSON{ + return jsonv2.MarshalEncode(enc, contentJSON{ Type: string(ContentTypeFile), - Data: json.RawMessage(dataBytes), + Data: jsontext.Value(dataBytes), }) } // UnmarshalJSON implements json.Unmarshaler for FileContent. func (f *FileContent) UnmarshalJSON(data []byte) error { var cj contentJSON - if err := json.Unmarshal(data, &cj); err != nil { + if err := jsonv2.Unmarshal(data, &cj); err != nil { return err } var aux struct { - MediaType string `json:"media_type"` - Data []byte `json:"data"` - ProviderMetadata map[string]json.RawMessage `json:"provider_metadata,omitempty"` + MediaType string `json:"media_type"` + Data []byte `json:"data"` + ProviderMetadata map[string]jsontext.Value `json:"provider_metadata,omitempty"` } - if err := json.Unmarshal(cj.Data, &aux); err != nil { + if err := jsonv2.Unmarshal(cj.Data, &aux); err != nil { return err } @@ -180,7 +181,7 @@ func (f *FileContent) UnmarshalJSON(data []byte) error { // MarshalJSON implements json.Marshaler for SourceContent. func (s SourceContent) MarshalJSON() ([]byte, error) { - dataBytes, err := json.Marshal(struct { + dataBytes, err := jsonv2.Marshal(struct { SourceType SourceType `json:"source_type"` ID string `json:"id"` URL string `json:"url,omitempty"` @@ -201,30 +202,30 @@ func (s SourceContent) MarshalJSON() ([]byte, error) { return nil, err } - return json.Marshal(contentJSON{ + return jsonv2.Marshal(contentJSON{ Type: string(ContentTypeSource), - Data: json.RawMessage(dataBytes), + Data: jsontext.Value(dataBytes), }) } // UnmarshalJSON implements json.Unmarshaler for SourceContent. func (s *SourceContent) UnmarshalJSON(data []byte) error { var cj contentJSON - if err := json.Unmarshal(data, &cj); err != nil { + if err := jsonv2.Unmarshal(data, &cj); err != nil { return err } var aux struct { - SourceType SourceType `json:"source_type"` - ID string `json:"id"` - URL string `json:"url,omitempty"` - Title string `json:"title,omitempty"` - MediaType string `json:"media_type,omitempty"` - Filename string `json:"filename,omitempty"` - ProviderMetadata map[string]json.RawMessage `json:"provider_metadata,omitempty"` + SourceType SourceType `json:"source_type"` + ID string `json:"id"` + URL string `json:"url,omitempty"` + Title string `json:"title,omitempty"` + MediaType string `json:"media_type,omitempty"` + Filename string `json:"filename,omitempty"` + ProviderMetadata map[string]jsontext.Value `json:"provider_metadata,omitempty"` } - if err := json.Unmarshal(cj.Data, &aux); err != nil { + if err := jsonv2.Unmarshal(cj.Data, &aux); err != nil { return err } @@ -253,7 +254,7 @@ func (t ToolCallContent) MarshalJSON() ([]byte, error) { msg := t.ValidationError.Error() validationErrMsg = &msg } - dataBytes, err := json.Marshal(struct { + dataBytes, err := jsonv2.Marshal(struct { ToolCallID string `json:"tool_call_id"` ToolName string `json:"tool_name"` Input string `json:"input"` @@ -274,30 +275,30 @@ func (t ToolCallContent) MarshalJSON() ([]byte, error) { return nil, err } - return json.Marshal(contentJSON{ + return jsonv2.Marshal(contentJSON{ Type: string(ContentTypeToolCall), - Data: json.RawMessage(dataBytes), + Data: jsontext.Value(dataBytes), }) } -// UnmarshalJSON implements json.Unmarshaler for ToolCallContent. -func (t *ToolCallContent) UnmarshalJSON(data []byte) error { +// UnmarshalJSONV2 implements jsonv2.UnmarshalerFrom for ToolCallContent. +func (t *ToolCallContent) UnmarshalJSONFrom(dec *jsontext.Decoder) error { var cj contentJSON - if err := json.Unmarshal(data, &cj); err != nil { + if err := jsonv2.UnmarshalDecode(dec, &cj); err != nil { return err } var aux struct { - ToolCallID string `json:"tool_call_id"` - ToolName string `json:"tool_name"` - Input string `json:"input"` - ProviderExecuted bool `json:"provider_executed"` - ProviderMetadata map[string]json.RawMessage `json:"provider_metadata,omitempty"` - Invalid bool `json:"invalid,omitempty"` - ValidationError *string `json:"validation_error,omitempty"` + ToolCallID string `json:"tool_call_id"` + ToolName string `json:"tool_name"` + Input string `json:"input"` + ProviderExecuted bool `json:"provider_executed"` + ProviderMetadata map[string]jsontext.Value `json:"provider_metadata,omitzero"` + Invalid bool `json:"invalid,omitzero"` + ValidationError *string `json:"validation_error,omitzero"` } - if err := json.Unmarshal(cj.Data, &aux); err != nil { + if err := jsonv2.Unmarshal(cj.Data, &aux); err != nil { return err } @@ -321,15 +322,15 @@ func (t *ToolCallContent) UnmarshalJSON(data []byte) error { return nil } -// MarshalJSON implements json.Marshaler for ToolResultContent. -func (t ToolResultContent) MarshalJSON() ([]byte, error) { - dataBytes, err := json.Marshal(struct { +// MarshalJSONV2 implements jsonv2.MarshalerTo for ToolResultContent. +func (t ToolResultContent) MarshalJSONTo(enc *jsontext.Encoder) error { + dataBytes, err := jsonv2.Marshal(struct { ToolCallID string `json:"tool_call_id"` ToolName string `json:"tool_name"` Result ToolResultOutputContent `json:"result"` - ClientMetadata string `json:"client_metadata,omitempty"` + ClientMetadata string `json:"client_metadata,omitzero"` ProviderExecuted bool `json:"provider_executed"` - ProviderMetadata ProviderMetadata `json:"provider_metadata,omitempty"` + ProviderMetadata ProviderMetadata `json:"provider_metadata,omitzero"` }{ ToolCallID: t.ToolCallID, ToolName: t.ToolName, @@ -339,32 +340,32 @@ func (t ToolResultContent) MarshalJSON() ([]byte, error) { ProviderMetadata: t.ProviderMetadata, }) if err != nil { - return nil, err + return err } - return json.Marshal(contentJSON{ + return jsonv2.MarshalEncode(enc, contentJSON{ Type: string(ContentTypeToolResult), - Data: json.RawMessage(dataBytes), + Data: jsontext.Value(dataBytes), }) } -// UnmarshalJSON implements json.Unmarshaler for ToolResultContent. -func (t *ToolResultContent) UnmarshalJSON(data []byte) error { +// UnmarshalJSONV2 implements jsonv2.UnmarshalerFrom for ToolResultContent. +func (t *ToolResultContent) UnmarshalJSONFrom(dec *jsontext.Decoder) error { var cj contentJSON - if err := json.Unmarshal(data, &cj); err != nil { + if err := jsonv2.UnmarshalDecode(dec, &cj); err != nil { return err } var aux struct { - ToolCallID string `json:"tool_call_id"` - ToolName string `json:"tool_name"` - Result json.RawMessage `json:"result"` - ClientMetadata string `json:"client_metadata,omitempty"` - ProviderExecuted bool `json:"provider_executed"` - ProviderMetadata map[string]json.RawMessage `json:"provider_metadata,omitempty"` + ToolCallID string `json:"tool_call_id"` + ToolName string `json:"tool_name"` + Result jsontext.Value `json:"result"` + ClientMetadata string `json:"client_metadata,omitzero"` + ProviderExecuted bool `json:"provider_executed"` + ProviderMetadata map[string]jsontext.Value `json:"provider_metadata,omitzero"` } - if err := json.Unmarshal(cj.Data, &aux); err != nil { + if err := jsonv2.Unmarshal(cj.Data, &aux); err != nil { return err } @@ -374,7 +375,7 @@ func (t *ToolResultContent) UnmarshalJSON(data []byte) error { t.ProviderExecuted = aux.ProviderExecuted // Unmarshal the Result field - result, err := UnmarshalToolResultOutputContent(aux.Result) + result, err := UnmarshalToolResultOutputContent([]byte(aux.Result)) if err != nil { return fmt.Errorf("failed to unmarshal tool result output: %w", err) } @@ -391,31 +392,31 @@ func (t *ToolResultContent) UnmarshalJSON(data []byte) error { return nil } -// MarshalJSON implements json.Marshaler for ToolResultOutputContentText. -func (t ToolResultOutputContentText) MarshalJSON() ([]byte, error) { +// MarshalJSONV2 implements jsonv2.MarshalerTo for ToolResultOutputContentText. +func (t ToolResultOutputContentText) MarshalJSONTo(enc *jsontext.Encoder) error { type alias ToolResultOutputContentText - dataBytes, err := json.Marshal(alias(t)) + dataBytes, err := jsonv2.Marshal(alias(t)) if err != nil { - return nil, err + return err } - return json.Marshal(toolResultOutputJSON{ + return jsonv2.MarshalEncode(enc, toolResultOutputJSON{ Type: string(ToolResultContentTypeText), - Data: json.RawMessage(dataBytes), + Data: jsontext.Value(dataBytes), }) } -// UnmarshalJSON implements json.Unmarshaler for ToolResultOutputContentText. -func (t *ToolResultOutputContentText) UnmarshalJSON(data []byte) error { +// UnmarshalJSONV2 implements jsonv2.UnmarshalerFrom for ToolResultOutputContentText. +func (t *ToolResultOutputContentText) UnmarshalJSONFrom(dec *jsontext.Decoder) error { var tr toolResultOutputJSON - if err := json.Unmarshal(data, &tr); err != nil { + if err := jsonv2.UnmarshalDecode(dec, &tr); err != nil { return err } type alias ToolResultOutputContentText var temp alias - if err := json.Unmarshal(tr.Data, &temp); err != nil { + if err := jsonv2.Unmarshal([]byte(tr.Data), &temp); err != nil { return err } @@ -423,31 +424,31 @@ func (t *ToolResultOutputContentText) UnmarshalJSON(data []byte) error { return nil } -// MarshalJSON implements json.Marshaler for ToolResultOutputContentError. -func (t ToolResultOutputContentError) MarshalJSON() ([]byte, error) { +// MarshalJSONV2 implements jsonv2.MarshalerTo for ToolResultOutputContentError. +func (t ToolResultOutputContentError) MarshalJSONTo(enc *jsontext.Encoder) error { errMsg := "" if t.Error != nil { errMsg = t.Error.Error() } - dataBytes, err := json.Marshal(struct { + dataBytes, err := jsonv2.Marshal(struct { Error string `json:"error"` }{ Error: errMsg, }) if err != nil { - return nil, err + return err } - return json.Marshal(toolResultOutputJSON{ + return jsonv2.MarshalEncode(enc, toolResultOutputJSON{ Type: string(ToolResultContentTypeError), - Data: json.RawMessage(dataBytes), + Data: jsontext.Value(dataBytes), }) } -// UnmarshalJSON implements json.Unmarshaler for ToolResultOutputContentError. -func (t *ToolResultOutputContentError) UnmarshalJSON(data []byte) error { +// UnmarshalJSONV2 implements jsonv2.UnmarshalerFrom for ToolResultOutputContentError. +func (t *ToolResultOutputContentError) UnmarshalJSONFrom(dec *jsontext.Decoder) error { var tr toolResultOutputJSON - if err := json.Unmarshal(data, &tr); err != nil { + if err := jsonv2.UnmarshalDecode(dec, &tr); err != nil { return err } @@ -455,7 +456,7 @@ func (t *ToolResultOutputContentError) UnmarshalJSON(data []byte) error { Error string `json:"error"` } - if err := json.Unmarshal(tr.Data, &temp); err != nil { + if err := jsonv2.Unmarshal([]byte(tr.Data), &temp); err != nil { return err } if temp.Error != "" { @@ -464,31 +465,31 @@ func (t *ToolResultOutputContentError) UnmarshalJSON(data []byte) error { return nil } -// MarshalJSON implements json.Marshaler for ToolResultOutputContentMedia. -func (t ToolResultOutputContentMedia) MarshalJSON() ([]byte, error) { +// MarshalJSONV2 implements jsonv2.MarshalerTo for ToolResultOutputContentMedia. +func (t ToolResultOutputContentMedia) MarshalJSONTo(enc *jsontext.Encoder) error { type alias ToolResultOutputContentMedia - dataBytes, err := json.Marshal(alias(t)) + dataBytes, err := jsonv2.Marshal(alias(t)) if err != nil { - return nil, err + return err } - return json.Marshal(toolResultOutputJSON{ + return jsonv2.MarshalEncode(enc, toolResultOutputJSON{ Type: string(ToolResultContentTypeMedia), - Data: json.RawMessage(dataBytes), + Data: jsontext.Value(dataBytes), }) } -// UnmarshalJSON implements json.Unmarshaler for ToolResultOutputContentMedia. -func (t *ToolResultOutputContentMedia) UnmarshalJSON(data []byte) error { +// UnmarshalJSONV2 implements jsonv2.UnmarshalerFrom for ToolResultOutputContentMedia. +func (t *ToolResultOutputContentMedia) UnmarshalJSONFrom(dec *jsontext.Decoder) error { var tr toolResultOutputJSON - if err := json.Unmarshal(data, &tr); err != nil { + if err := jsonv2.UnmarshalDecode(dec, &tr); err != nil { return err } type alias ToolResultOutputContentMedia var temp alias - if err := json.Unmarshal(tr.Data, &temp); err != nil { + if err := jsonv2.Unmarshal([]byte(tr.Data), &temp); err != nil { return err } @@ -496,38 +497,38 @@ func (t *ToolResultOutputContentMedia) UnmarshalJSON(data []byte) error { return nil } -// MarshalJSON implements json.Marshaler for TextPart. -func (t TextPart) MarshalJSON() ([]byte, error) { - dataBytes, err := json.Marshal(struct { +// MarshalJSONV2 implements jsonv2.MarshalerTo for TextPart. +func (t TextPart) MarshalJSONTo(enc *jsontext.Encoder) error { + dataBytes, err := jsonv2.Marshal(struct { Text string `json:"text"` - ProviderOptions ProviderOptions `json:"provider_options,omitempty"` + ProviderOptions ProviderOptions `json:"provider_options,omitzero"` }{ Text: t.Text, ProviderOptions: t.ProviderOptions, }) if err != nil { - return nil, err + return err } - return json.Marshal(messagePartJSON{ + return jsonv2.MarshalEncode(enc, messagePartJSON{ Type: string(ContentTypeText), - Data: json.RawMessage(dataBytes), + Data: jsontext.Value(dataBytes), }) } -// UnmarshalJSON implements json.Unmarshaler for TextPart. -func (t *TextPart) UnmarshalJSON(data []byte) error { +// UnmarshalJSONV2 implements jsonv2.UnmarshalerFrom for TextPart. +func (t *TextPart) UnmarshalJSONFrom(dec *jsontext.Decoder) error { var mpj messagePartJSON - if err := json.Unmarshal(data, &mpj); err != nil { + if err := jsonv2.UnmarshalDecode(dec, &mpj); err != nil { return err } var aux struct { - Text string `json:"text"` - ProviderOptions map[string]json.RawMessage `json:"provider_options,omitempty"` + Text string `json:"text"` + ProviderOptions map[string]jsontext.Value `json:"provider_options,omitzero"` } - if err := json.Unmarshal(mpj.Data, &aux); err != nil { + if err := jsonv2.Unmarshal([]byte(mpj.Data), &aux); err != nil { return err } @@ -544,38 +545,38 @@ func (t *TextPart) UnmarshalJSON(data []byte) error { return nil } -// MarshalJSON implements json.Marshaler for ReasoningPart. -func (r ReasoningPart) MarshalJSON() ([]byte, error) { - dataBytes, err := json.Marshal(struct { +// MarshalJSONV2 implements jsonv2.MarshalerTo for ReasoningPart. +func (r ReasoningPart) MarshalJSONTo(enc *jsontext.Encoder) error { + dataBytes, err := jsonv2.Marshal(struct { Text string `json:"text"` - ProviderOptions ProviderOptions `json:"provider_options,omitempty"` + ProviderOptions ProviderOptions `json:"provider_options,omitzero"` }{ Text: r.Text, ProviderOptions: r.ProviderOptions, }) if err != nil { - return nil, err + return err } - return json.Marshal(messagePartJSON{ + return jsonv2.MarshalEncode(enc, messagePartJSON{ Type: string(ContentTypeReasoning), - Data: json.RawMessage(dataBytes), + Data: jsontext.Value(dataBytes), }) } -// UnmarshalJSON implements json.Unmarshaler for ReasoningPart. -func (r *ReasoningPart) UnmarshalJSON(data []byte) error { +// UnmarshalJSONV2 implements jsonv2.UnmarshalerFrom for ReasoningPart. +func (r *ReasoningPart) UnmarshalJSONFrom(dec *jsontext.Decoder) error { var mpj messagePartJSON - if err := json.Unmarshal(data, &mpj); err != nil { + if err := jsonv2.UnmarshalDecode(dec, &mpj); err != nil { return err } var aux struct { - Text string `json:"text"` - ProviderOptions map[string]json.RawMessage `json:"provider_options,omitempty"` + Text string `json:"text"` + ProviderOptions map[string]jsontext.Value `json:"provider_options,omitzero"` } - if err := json.Unmarshal(mpj.Data, &aux); err != nil { + if err := jsonv2.Unmarshal([]byte(mpj.Data), &aux); err != nil { return err } @@ -592,13 +593,13 @@ func (r *ReasoningPart) UnmarshalJSON(data []byte) error { return nil } -// MarshalJSON implements json.Marshaler for FilePart. -func (f FilePart) MarshalJSON() ([]byte, error) { - dataBytes, err := json.Marshal(struct { +// MarshalJSONV2 implements jsonv2.MarshalerTo for FilePart. +func (f FilePart) MarshalJSONTo(enc *jsontext.Encoder) error { + dataBytes, err := jsonv2.Marshal(struct { Filename string `json:"filename"` Data []byte `json:"data"` MediaType string `json:"media_type"` - ProviderOptions ProviderOptions `json:"provider_options,omitempty"` + ProviderOptions ProviderOptions `json:"provider_options,omitzero"` }{ Filename: f.Filename, Data: f.Data, @@ -606,30 +607,30 @@ func (f FilePart) MarshalJSON() ([]byte, error) { ProviderOptions: f.ProviderOptions, }) if err != nil { - return nil, err + return err } - return json.Marshal(messagePartJSON{ + return jsonv2.MarshalEncode(enc, messagePartJSON{ Type: string(ContentTypeFile), - Data: json.RawMessage(dataBytes), + Data: jsontext.Value(dataBytes), }) } -// UnmarshalJSON implements json.Unmarshaler for FilePart. -func (f *FilePart) UnmarshalJSON(data []byte) error { +// UnmarshalJSONV2 implements jsonv2.UnmarshalerFrom for FilePart. +func (f *FilePart) UnmarshalJSONFrom(dec *jsontext.Decoder) error { var mpj messagePartJSON - if err := json.Unmarshal(data, &mpj); err != nil { + if err := jsonv2.UnmarshalDecode(dec, &mpj); err != nil { return err } var aux struct { - Filename string `json:"filename"` - Data []byte `json:"data"` - MediaType string `json:"media_type"` - ProviderOptions map[string]json.RawMessage `json:"provider_options,omitempty"` + Filename string `json:"filename"` + Data []byte `json:"data"` + MediaType string `json:"media_type"` + ProviderOptions map[string]jsontext.Value `json:"provider_options,omitzero"` } - if err := json.Unmarshal(mpj.Data, &aux); err != nil { + if err := jsonv2.Unmarshal([]byte(mpj.Data), &aux); err != nil { return err } @@ -648,14 +649,14 @@ func (f *FilePart) UnmarshalJSON(data []byte) error { return nil } -// MarshalJSON implements json.Marshaler for ToolCallPart. -func (t ToolCallPart) MarshalJSON() ([]byte, error) { - dataBytes, err := json.Marshal(struct { +// MarshalJSONV2 implements jsonv2.MarshalerTo for ToolCallPart. +func (t ToolCallPart) MarshalJSONTo(enc *jsontext.Encoder) error { + dataBytes, err := jsonv2.Marshal(struct { ToolCallID string `json:"tool_call_id"` ToolName string `json:"tool_name"` Input string `json:"input"` ProviderExecuted bool `json:"provider_executed"` - ProviderOptions ProviderOptions `json:"provider_options,omitempty"` + ProviderOptions ProviderOptions `json:"provider_options,omitzero"` }{ ToolCallID: t.ToolCallID, ToolName: t.ToolName, @@ -664,31 +665,31 @@ func (t ToolCallPart) MarshalJSON() ([]byte, error) { ProviderOptions: t.ProviderOptions, }) if err != nil { - return nil, err + return err } - return json.Marshal(messagePartJSON{ + return jsonv2.MarshalEncode(enc, messagePartJSON{ Type: string(ContentTypeToolCall), - Data: json.RawMessage(dataBytes), + Data: jsontext.Value(dataBytes), }) } -// UnmarshalJSON implements json.Unmarshaler for ToolCallPart. -func (t *ToolCallPart) UnmarshalJSON(data []byte) error { +// UnmarshalJSONV2 implements jsonv2.UnmarshalerFrom for ToolCallPart. +func (t *ToolCallPart) UnmarshalJSONFrom(dec *jsontext.Decoder) error { var mpj messagePartJSON - if err := json.Unmarshal(data, &mpj); err != nil { + if err := jsonv2.UnmarshalDecode(dec, &mpj); err != nil { return err } var aux struct { - ToolCallID string `json:"tool_call_id"` - ToolName string `json:"tool_name"` - Input string `json:"input"` - ProviderExecuted bool `json:"provider_executed"` - ProviderOptions map[string]json.RawMessage `json:"provider_options,omitempty"` + ToolCallID string `json:"tool_call_id"` + ToolName string `json:"tool_name"` + Input string `json:"input"` + ProviderExecuted bool `json:"provider_executed"` + ProviderOptions map[string]jsontext.Value `json:"provider_options,omitzero"` } - if err := json.Unmarshal(mpj.Data, &aux); err != nil { + if err := jsonv2.Unmarshal([]byte(mpj.Data), &aux); err != nil { return err } @@ -708,48 +709,48 @@ func (t *ToolCallPart) UnmarshalJSON(data []byte) error { return nil } -// MarshalJSON implements json.Marshaler for ToolResultPart. -func (t ToolResultPart) MarshalJSON() ([]byte, error) { - dataBytes, err := json.Marshal(struct { +// MarshalJSONV2 implements jsonv2.MarshalerTo for ToolResultPart. +func (t ToolResultPart) MarshalJSONTo(enc *jsontext.Encoder) error { + dataBytes, err := jsonv2.Marshal(struct { ToolCallID string `json:"tool_call_id"` Output ToolResultOutputContent `json:"output"` - ProviderOptions ProviderOptions `json:"provider_options,omitempty"` + ProviderOptions ProviderOptions `json:"provider_options,omitzero"` }{ ToolCallID: t.ToolCallID, Output: t.Output, ProviderOptions: t.ProviderOptions, }) if err != nil { - return nil, err + return err } - return json.Marshal(messagePartJSON{ + return jsonv2.MarshalEncode(enc, messagePartJSON{ Type: string(ContentTypeToolResult), - Data: json.RawMessage(dataBytes), + Data: jsontext.Value(dataBytes), }) } -// UnmarshalJSON implements json.Unmarshaler for ToolResultPart. -func (t *ToolResultPart) UnmarshalJSON(data []byte) error { +// UnmarshalJSONV2 implements jsonv2.UnmarshalerFrom for ToolResultPart. +func (t *ToolResultPart) UnmarshalJSONFrom(dec *jsontext.Decoder) error { var mpj messagePartJSON - if err := json.Unmarshal(data, &mpj); err != nil { + if err := jsonv2.UnmarshalDecode(dec, &mpj); err != nil { return err } var aux struct { - ToolCallID string `json:"tool_call_id"` - Output json.RawMessage `json:"output"` - ProviderOptions map[string]json.RawMessage `json:"provider_options,omitempty"` + ToolCallID string `json:"tool_call_id"` + Output jsontext.Value `json:"output"` + ProviderOptions map[string]jsontext.Value `json:"provider_options,omitzero"` } - if err := json.Unmarshal(mpj.Data, &aux); err != nil { + if err := jsonv2.Unmarshal([]byte(mpj.Data), &aux); err != nil { return err } t.ToolCallID = aux.ToolCallID // Unmarshal the Output field - output, err := UnmarshalToolResultOutputContent(aux.Output) + output, err := UnmarshalToolResultOutputContent([]byte(aux.Output)) if err != nil { return fmt.Errorf("failed to unmarshal tool result output: %w", err) } @@ -766,15 +767,15 @@ func (t *ToolResultPart) UnmarshalJSON(data []byte) error { return nil } -// UnmarshalJSON implements json.Unmarshaler for Message. -func (m *Message) UnmarshalJSON(data []byte) error { +// UnmarshalJSONV2 implements jsonv2.UnmarshalerFrom for Message. +func (m *Message) UnmarshalJSONFrom(dec *jsontext.Decoder) error { var aux struct { - Role MessageRole `json:"role"` - Content []json.RawMessage `json:"content"` - ProviderOptions map[string]json.RawMessage `json:"provider_options"` + Role MessageRole `json:"role"` + Content []jsontext.Value `json:"content"` + 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 } @@ -782,7 +783,7 @@ func (m *Message) UnmarshalJSON(data []byte) error { m.Content = make([]MessagePart, len(aux.Content)) for i, rawPart := range aux.Content { - part, err := UnmarshalMessagePart(rawPart) + part, err := UnmarshalMessagePart([]byte(rawPart)) if err != nil { return fmt.Errorf("failed to unmarshal message part at index %d: %w", i, err) } @@ -800,13 +801,13 @@ func (m *Message) UnmarshalJSON(data []byte) error { return nil } -// MarshalJSON implements json.Marshaler for FunctionTool. -func (f FunctionTool) MarshalJSON() ([]byte, error) { - dataBytes, err := json.Marshal(struct { +// MarshalJSONV2 implements jsonv2.MarshalerTo for FunctionTool. +func (f FunctionTool) MarshalJSONTo(enc *jsontext.Encoder) error { + dataBytes, err := jsonv2.Marshal(struct { Name string `json:"name"` Description string `json:"description"` InputSchema map[string]any `json:"input_schema"` - ProviderOptions ProviderOptions `json:"provider_options,omitempty"` + ProviderOptions ProviderOptions `json:"provider_options,omitzero"` }{ Name: f.Name, Description: f.Description, @@ -814,30 +815,30 @@ func (f FunctionTool) MarshalJSON() ([]byte, error) { ProviderOptions: f.ProviderOptions, }) if err != nil { - return nil, err + return err } - return json.Marshal(toolJSON{ + return jsonv2.MarshalEncode(enc, toolJSON{ Type: string(ToolTypeFunction), - Data: json.RawMessage(dataBytes), + Data: jsontext.Value(dataBytes), }) } -// UnmarshalJSON implements json.Unmarshaler for FunctionTool. -func (f *FunctionTool) UnmarshalJSON(data []byte) error { +// UnmarshalJSONV2 implements jsonv2.UnmarshalerFrom for FunctionTool. +func (f *FunctionTool) UnmarshalJSONFrom(dec *jsontext.Decoder) error { var tj toolJSON - if err := json.Unmarshal(data, &tj); err != nil { + if err := jsonv2.UnmarshalDecode(dec, &tj); err != nil { return err } var aux struct { - Name string `json:"name"` - Description string `json:"description"` - InputSchema map[string]any `json:"input_schema"` - ProviderOptions map[string]json.RawMessage `json:"provider_options,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + InputSchema map[string]any `json:"input_schema"` + ProviderOptions map[string]jsontext.Value `json:"provider_options,omitzero"` } - if err := json.Unmarshal(tj.Data, &aux); err != nil { + if err := jsonv2.Unmarshal([]byte(tj.Data), &aux); err != nil { return err } @@ -856,31 +857,31 @@ func (f *FunctionTool) UnmarshalJSON(data []byte) error { return nil } -// MarshalJSON implements json.Marshaler for ProviderDefinedTool. -func (p ProviderDefinedTool) MarshalJSON() ([]byte, error) { +// MarshalJSONV2 implements jsonv2.MarshalerTo for ProviderDefinedTool. +func (p ProviderDefinedTool) MarshalJSONTo(enc *jsontext.Encoder) error { type alias ProviderDefinedTool - dataBytes, err := json.Marshal(alias(p)) + dataBytes, err := jsonv2.Marshal(alias(p)) if err != nil { - return nil, err + return err } - return json.Marshal(toolJSON{ + return jsonv2.MarshalEncode(enc, toolJSON{ Type: string(ToolTypeProviderDefined), - Data: json.RawMessage(dataBytes), + Data: jsontext.Value(dataBytes), }) } -// UnmarshalJSON implements json.Unmarshaler for ProviderDefinedTool. -func (p *ProviderDefinedTool) UnmarshalJSON(data []byte) error { +// UnmarshalJSONV2 implements jsonv2.UnmarshalerFrom for ProviderDefinedTool. +func (p *ProviderDefinedTool) UnmarshalJSONFrom(dec *jsontext.Decoder) error { var tj toolJSON - if err := json.Unmarshal(data, &tj); err != nil { + if err := jsonv2.UnmarshalDecode(dec, &tj); err != nil { return err } type alias ProviderDefinedTool var aux alias - if err := json.Unmarshal(tj.Data, &aux); err != nil { + if err := jsonv2.Unmarshal([]byte(tj.Data), &aux); err != nil { return err } @@ -892,20 +893,20 @@ func (p *ProviderDefinedTool) UnmarshalJSON(data []byte) error { // UnmarshalTool unmarshals JSON into the appropriate Tool type. func UnmarshalTool(data []byte) (Tool, error) { var tj toolJSON - if err := json.Unmarshal(data, &tj); err != nil { + if err := jsonv2.Unmarshal(data, &tj); err != nil { return nil, err } switch ToolType(tj.Type) { case ToolTypeFunction: var tool FunctionTool - if err := tool.UnmarshalJSON(data); err != nil { + if err := jsonv2.Unmarshal(data, &tool); err != nil { return nil, err } return tool, nil case ToolTypeProviderDefined: var tool ProviderDefinedTool - if err := tool.UnmarshalJSON(data); err != nil { + if err := jsonv2.Unmarshal(data, &tool); err != nil { return nil, err } return tool, nil @@ -917,44 +918,44 @@ func UnmarshalTool(data []byte) (Tool, error) { // UnmarshalContent unmarshals JSON into the appropriate Content type. func UnmarshalContent(data []byte) (Content, error) { var cj contentJSON - if err := json.Unmarshal(data, &cj); err != nil { + if err := jsonv2.Unmarshal(data, &cj); err != nil { return nil, err } switch ContentType(cj.Type) { case ContentTypeText: var content TextContent - if err := content.UnmarshalJSON(data); err != nil { + if err := jsonv2.Unmarshal(data, &content); err != nil { return nil, err } return content, nil case ContentTypeReasoning: var content ReasoningContent - if err := content.UnmarshalJSON(data); err != nil { + if err := jsonv2.Unmarshal(data, &content); err != nil { return nil, err } return content, nil case ContentTypeFile: var content FileContent - if err := content.UnmarshalJSON(data); err != nil { + if err := jsonv2.Unmarshal(data, &content); err != nil { return nil, err } return content, nil case ContentTypeSource: var content SourceContent - if err := content.UnmarshalJSON(data); err != nil { + if err := jsonv2.Unmarshal(data, &content); err != nil { return nil, err } return content, nil case ContentTypeToolCall: var content ToolCallContent - if err := content.UnmarshalJSON(data); err != nil { + if err := jsonv2.Unmarshal(data, &content); err != nil { return nil, err } return content, nil case ContentTypeToolResult: var content ToolResultContent - if err := content.UnmarshalJSON(data); err != nil { + if err := jsonv2.Unmarshal(data, &content); err != nil { return nil, err } return content, nil @@ -966,38 +967,38 @@ func UnmarshalContent(data []byte) (Content, error) { // UnmarshalMessagePart unmarshals JSON into the appropriate MessagePart type. func UnmarshalMessagePart(data []byte) (MessagePart, error) { var mpj messagePartJSON - if err := json.Unmarshal(data, &mpj); err != nil { + if err := jsonv2.Unmarshal(data, &mpj); err != nil { return nil, err } switch ContentType(mpj.Type) { case ContentTypeText: var part TextPart - if err := part.UnmarshalJSON(data); err != nil { + if err := jsonv2.Unmarshal(data, &part); err != nil { return nil, err } return part, nil case ContentTypeReasoning: var part ReasoningPart - if err := part.UnmarshalJSON(data); err != nil { + if err := jsonv2.Unmarshal(data, &part); err != nil { return nil, err } return part, nil case ContentTypeFile: var part FilePart - if err := part.UnmarshalJSON(data); err != nil { + if err := jsonv2.Unmarshal(data, &part); err != nil { return nil, err } return part, nil case ContentTypeToolCall: var part ToolCallPart - if err := part.UnmarshalJSON(data); err != nil { + if err := jsonv2.Unmarshal(data, &part); err != nil { return nil, err } return part, nil case ContentTypeToolResult: var part ToolResultPart - if err := part.UnmarshalJSON(data); err != nil { + if err := jsonv2.Unmarshal(data, &part); err != nil { return nil, err } return part, nil @@ -1009,26 +1010,26 @@ func UnmarshalMessagePart(data []byte) (MessagePart, error) { // UnmarshalToolResultOutputContent unmarshals JSON into the appropriate ToolResultOutputContent type. func UnmarshalToolResultOutputContent(data []byte) (ToolResultOutputContent, error) { var troj toolResultOutputJSON - if err := json.Unmarshal(data, &troj); err != nil { + if err := jsonv2.Unmarshal(data, &troj); err != nil { return nil, err } switch ToolResultContentType(troj.Type) { case ToolResultContentTypeText: var content ToolResultOutputContentText - if err := content.UnmarshalJSON(data); err != nil { + if err := jsonv2.Unmarshal(data, &content); err != nil { return nil, err } return content, nil case ToolResultContentTypeError: var content ToolResultOutputContentError - if err := content.UnmarshalJSON(data); err != nil { + if err := jsonv2.Unmarshal(data, &content); err != nil { return nil, err } return content, nil case ToolResultContentTypeMedia: var content ToolResultOutputContentMedia - if err := content.UnmarshalJSON(data); err != nil { + if err := jsonv2.Unmarshal(data, &content); err != nil { return nil, err } return content, nil diff --git a/internal/fantasy/json_test.go b/internal/fantasy/json_test.go index b1088434a..c83da0350 100644 --- a/internal/fantasy/json_test.go +++ b/internal/fantasy/json_test.go @@ -1,8 +1,8 @@ package fantasy import ( - "encoding/json" "errors" + jsonv2 "github.com/go-json-experiment/json" "reflect" "testing" ) @@ -156,14 +156,14 @@ func TestMessageJSONSerialization(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Marshal the message - data, err := json.Marshal(tt.message) + data, err := jsonv2.Marshal(tt.message) if err != nil { t.Fatalf("failed to marshal message: %v", err) } // Unmarshal back var decoded Message - err = json.Unmarshal(data, &decoded) + err = jsonv2.Unmarshal(data, &decoded) if err != nil { 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) { msg := NewUserMessage("Hello") - data, err := json.Marshal(msg) + data, err := jsonv2.Marshal(msg) if err != nil { t.Fatalf("failed to marshal: %v", err) } 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) } @@ -324,13 +324,13 @@ func TestHelperFunctions(t *testing.T) { }, ) - data, err := json.Marshal(msg) + data, err := jsonv2.Marshal(msg) if err != nil { t.Fatalf("failed to marshal: %v", err) } 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) } @@ -360,13 +360,13 @@ func TestHelperFunctions(t *testing.T) { t.Run("NewSystemMessage - single prompt", func(t *testing.T) { msg := NewSystemMessage("You are a helpful assistant.") - data, err := json.Marshal(msg) + data, err := jsonv2.Marshal(msg) if err != nil { t.Fatalf("failed to marshal: %v", err) } 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) } @@ -387,13 +387,13 @@ func TestHelperFunctions(t *testing.T) { t.Run("NewSystemMessage - multiple prompts", func(t *testing.T) { msg := NewSystemMessage("First instruction", "Second instruction", "Third instruction") - data, err := json.Marshal(msg) + data, err := jsonv2.Marshal(msg) if err != nil { t.Fatalf("failed to marshal: %v", err) } 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) } @@ -420,13 +420,13 @@ func TestEdgeCases(t *testing.T) { }, } - data, err := json.Marshal(msg) + data, err := jsonv2.Marshal(msg) if err != nil { t.Fatalf("failed to marshal: %v", err) } 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) } @@ -449,13 +449,13 @@ func TestEdgeCases(t *testing.T) { }, } - data, err := json.Marshal(msg) + data, err := jsonv2.Marshal(msg) if err != nil { t.Fatalf("failed to marshal: %v", err) } 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) } @@ -478,13 +478,13 @@ func TestEdgeCases(t *testing.T) { }, } - data, err := json.Marshal(msg) + data, err := jsonv2.Marshal(msg) if err != nil { t.Fatalf("failed to marshal: %v", err) } 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) } @@ -502,13 +502,13 @@ func TestEdgeCases(t *testing.T) { }, } - data, err := json.Marshal(msg) + data, err := jsonv2.Marshal(msg) if err != nil { t.Fatalf("failed to marshal: %v", err) } 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) } @@ -533,7 +533,7 @@ func TestInvalidJSONHandling(t *testing.T) { }` var msg Message - err := json.Unmarshal([]byte(invalidJSON), &msg) + err := jsonv2.Unmarshal([]byte(invalidJSON), &msg) if err == nil { t.Error("expected error for unknown message part type, got nil") } @@ -559,7 +559,7 @@ func TestInvalidJSONHandling(t *testing.T) { }` var msg Message - err := json.Unmarshal([]byte(invalidJSON), &msg) + err := jsonv2.Unmarshal([]byte(invalidJSON), &msg) if err == 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": [` var msg Message - err := json.Unmarshal([]byte(invalidJSON), &msg) + err := jsonv2.Unmarshal([]byte(invalidJSON), &msg) if err == nil { t.Error("expected error for malformed JSON, got nil") } @@ -584,7 +584,7 @@ type mockProviderData struct { func (m mockProviderData) Options() {} func (m mockProviderData) Type() string { return "mock" } func (m mockProviderData) MarshalJSON() ([]byte, error) { - return json.Marshal(struct { + return jsonv2.Marshal(struct { Type string `json:"type"` mockProviderData }{ @@ -598,7 +598,7 @@ func (m *mockProviderData) UnmarshalJSON(data []byte) error { Type string `json:"type"` mockProviderData } - if err := json.Unmarshal(data, &aux); err != nil { + if err := jsonv2.Unmarshal(data, &aux); err != nil { return err } *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 { t.Fatalf("failed to marshal prompt: %v", err) } 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) } @@ -672,14 +672,14 @@ func TestStreamPartErrorSerialization(t *testing.T) { } // Marshal the stream part - data, err := json.Marshal(streamPart) + data, err := jsonv2.Marshal(streamPart) if err != nil { t.Fatalf("failed to marshal stream part: %v", err) } // Unmarshal back var decoded StreamPart - err = json.Unmarshal(data, &decoded) + err = jsonv2.Unmarshal(data, &decoded) if err != nil { t.Fatalf("failed to unmarshal stream part: %v", err) } @@ -728,7 +728,7 @@ func TestStreamPartErrorSerialization(t *testing.T) { }` var streamPart StreamPart - err := json.Unmarshal([]byte(jsonData), &streamPart) + err := jsonv2.Unmarshal([]byte(jsonData), &streamPart) if err != nil { t.Fatalf("failed to unmarshal stream part: %v", err) } diff --git a/internal/fantasy/jsonrepair/jsonrepair.go b/internal/fantasy/jsonrepair/jsonrepair.go index 26aa5b98a..cf7b76921 100644 --- a/internal/fantasy/jsonrepair/jsonrepair.go +++ b/internal/fantasy/jsonrepair/jsonrepair.go @@ -3,7 +3,6 @@ package jsonrepair import ( "bytes" - "encoding/json" "errors" "reflect" "slices" @@ -1521,7 +1520,7 @@ func normalizeValue(value any) any { } return items case numberValue: - return json.Number(v.raw) + return v default: return v } @@ -1541,8 +1540,6 @@ func writeValue(buf *bytes.Buffer, value any, ensureASCII bool) { buf.WriteByte('"') case numberValue: buf.WriteString(v.raw) - case json.Number: - buf.WriteString(v.String()) case bool: if v { buf.WriteString("true") diff --git a/internal/fantasy/jsonrepair/jsonrepair_test.go b/internal/fantasy/jsonrepair/jsonrepair_test.go index 5340543ff..5364932ea 100644 --- a/internal/fantasy/jsonrepair/jsonrepair_test.go +++ b/internal/fantasy/jsonrepair/jsonrepair_test.go @@ -1,7 +1,6 @@ package jsonrepair import ( - "encoding/json" "reflect" "strings" "testing" @@ -227,7 +226,7 @@ func TestLoads(t *testing.T) { input: "{\"name\": \"John\", \"age\": 30, \"city\": \"New York\"}", want: map[string]any{ "name": "John", - "age": json.Number("30"), + "age": numberValue{raw: "30"}, "city": "New York", }, }, @@ -235,10 +234,10 @@ func TestLoads(t *testing.T) { name: "array_numbers", input: "[1, 2, 3, 4]", want: []any{ - json.Number("1"), - json.Number("2"), - json.Number("3"), - json.Number("4"), + numberValue{raw: "1"}, + numberValue{raw: "2"}, + numberValue{raw: "3"}, + numberValue{raw: "4"}, }, }, { @@ -453,10 +452,10 @@ func TestParseArrayObjects(t *testing.T) { name: "numbers_array", input: "[1, 2, 3, 4]", want: []any{ - json.Number("1"), - json.Number("2"), - json.Number("3"), - json.Number("4"), + numberValue{raw: "1"}, + numberValue{raw: "2"}, + numberValue{raw: "3"}, + numberValue{raw: "4"}, }, }, { @@ -721,25 +720,25 @@ func TestParseNumber(t *testing.T) { { name: "integer", input: "1", - want: json.Number("1"), + want: numberValue{raw: "1"}, }, { name: "float", input: "1.2", - want: json.Number("1.2"), + want: numberValue{raw: "1.2"}, }, { name: "underscored_integer", input: "{\"value\": 82_461_110}", want: map[string]any{ - "value": json.Number("82461110"), + "value": numberValue{raw: "82461110"}, }, }, { name: "underscored_float", input: "{\"value\": 1_234.5_6}", 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 }", want: map[string]any{ "key": "value", - "key2": json.Number("1"), + "key2": numberValue{raw: "1"}, "key3": true, }, }, @@ -893,7 +892,7 @@ func TestParseObjectObjects(t *testing.T) { input: "{ \"key\": value, \"key2\": 1 \"key3\": null }", want: map[string]any{ "key": "value", - "key2": json.Number("1"), + "key2": numberValue{raw: "1"}, "key3": nil, }, }, diff --git a/internal/fantasy/model_json.go b/internal/fantasy/model_json.go index 2fd883a2f..34e951433 100644 --- a/internal/fantasy/model_json.go +++ b/internal/fantasy/model_json.go @@ -1,26 +1,27 @@ package fantasy import ( - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" ) -// UnmarshalJSON implements json.Unmarshaler for Call. -func (c *Call) UnmarshalJSON(data []byte) error { +// UnmarshalJSONFrom implements jsonv2.UnmarshalerFrom for Call. +func (c *Call) UnmarshalJSONFrom(dec *jsontext.Decoder) error { var aux struct { - Prompt Prompt `json:"prompt"` - MaxOutputTokens *int64 `json:"max_output_tokens"` - Temperature *float64 `json:"temperature"` - TopP *float64 `json:"top_p"` - TopK *int64 `json:"top_k"` - PresencePenalty *float64 `json:"presence_penalty"` - FrequencyPenalty *float64 `json:"frequency_penalty"` - Tools []json.RawMessage `json:"tools"` - ToolChoice *ToolChoice `json:"tool_choice"` - ProviderOptions map[string]json.RawMessage `json:"provider_options"` + Prompt Prompt `json:"prompt"` + MaxOutputTokens *int64 `json:"max_output_tokens"` + Temperature *float64 `json:"temperature"` + TopP *float64 `json:"top_p"` + TopK *int64 `json:"top_k"` + PresencePenalty *float64 `json:"presence_penalty"` + FrequencyPenalty *float64 `json:"frequency_penalty"` + Tools []jsontext.Value `json:"tools"` + ToolChoice *ToolChoice `json:"tool_choice"` + 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 } @@ -36,7 +37,7 @@ func (c *Call) UnmarshalJSON(data []byte) error { // Unmarshal Tools slice c.Tools = make([]Tool, len(aux.Tools)) for i, rawTool := range aux.Tools { - tool, err := UnmarshalTool(rawTool) + tool, err := UnmarshalTool([]byte(rawTool)) if err != nil { 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 } -// UnmarshalJSON implements json.Unmarshaler for Response. -func (r *Response) UnmarshalJSON(data []byte) error { +// UnmarshalJSONFrom implements jsonv2.UnmarshalerFrom for Response. +func (r *Response) UnmarshalJSONFrom(dec *jsontext.Decoder) error { var aux struct { - Content json.RawMessage `json:"content"` - FinishReason FinishReason `json:"finish_reason"` - Usage Usage `json:"usage"` - Warnings []CallWarning `json:"warnings"` - ProviderMetadata map[string]json.RawMessage `json:"provider_metadata"` + Content jsontext.Value `json:"content"` + FinishReason FinishReason `json:"finish_reason"` + Usage Usage `json:"usage"` + Warnings []CallWarning `json:"warnings"` + 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 } @@ -73,16 +74,14 @@ func (r *Response) UnmarshalJSON(data []byte) error { r.Usage = aux.Usage r.Warnings = aux.Warnings - // Unmarshal ResponseContent (need to know the type definition) - // If ResponseContent is []Content: - var rawContent []json.RawMessage - if err := json.Unmarshal(aux.Content, &rawContent); err != nil { + var rawContent []jsontext.Value + if err := jsonv2.Unmarshal([]byte(aux.Content), &rawContent); err != nil { return err } content := make([]Content, len(rawContent)) for i, rawItem := range rawContent { - item, err := UnmarshalContent(rawItem) + item, err := UnmarshalContent([]byte(rawItem)) if err != nil { 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 } -// MarshalJSON implements json.Marshaler for StreamPart. -func (s StreamPart) MarshalJSON() ([]byte, error) { +// MarshalJSONTo implements jsonv2.MarshalerTo for StreamPart. +func (s StreamPart) MarshalJSONTo(enc *jsontext.Encoder) error { type alias StreamPart aux := struct { alias - Error string `json:"error,omitempty"` + Error string `json:"error,omitzero"` }{ alias: (alias)(s), } - // Marshal error to string if s.Error != nil { aux.Error = s.Error.Error() } - // Clear the original Error field to avoid duplicate marshaling aux.alias.Error = nil - return json.Marshal(aux) + return jsonv2.MarshalEncode(enc, aux) } -// UnmarshalJSON implements json.Unmarshaler for StreamPart. -func (s *StreamPart) UnmarshalJSON(data []byte) error { +// UnmarshalJSONFrom implements jsonv2.UnmarshalerFrom for StreamPart. +func (s *StreamPart) UnmarshalJSONFrom(dec *jsontext.Decoder) error { type alias StreamPart aux := struct { *alias - Error string `json:"error"` - ProviderMetadata map[string]json.RawMessage `json:"provider_metadata"` + Error string `json:"error"` + ProviderMetadata map[string]jsontext.Value `json:"provider_metadata"` }{ alias: (*alias)(s), } - if err := json.Unmarshal(data, &aux); err != nil { + if err := jsonv2.UnmarshalDecode(dec, &aux); err != nil { return err } - // Unmarshal error string back to error type if aux.Error != "" { s.Error = fmt.Errorf("%s", aux.Error) } - // Unmarshal ProviderMetadata if len(aux.ProviderMetadata) > 0 { metadata, err := UnmarshalProviderMetadata(aux.ProviderMetadata) if err != nil { diff --git a/internal/fantasy/object.go b/internal/fantasy/object.go index 4b8aed369..5b21cc917 100644 --- a/internal/fantasy/object.go +++ b/internal/fantasy/object.go @@ -2,8 +2,8 @@ package fantasy import ( "context" - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" "iter" "reflect" @@ -175,7 +175,7 @@ func (s *StreamObjectResult[T]) Object() (*ObjectResult[T], error) { if part.Object != nil { if err := unmarshalObject(part.Object, &finalObject); err == nil { hasObject = true - if jsonBytes, err := json.Marshal(part.Object); err == nil { + if jsonBytes, err := jsonv2.Marshal(part.Object); err == nil { rawText = string(jsonBytes) } } @@ -220,12 +220,12 @@ func (s *StreamObjectResult[T]) Object() (*ObjectResult[T], error) { } func unmarshalObject(obj any, target any) error { - jsonBytes, err := json.Marshal(obj) + jsonBytes, err := jsonv2.Marshal(obj) if err != nil { 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) } diff --git a/internal/fantasy/object/object.go b/internal/fantasy/object/object.go index c62b1cbec..d058fff47 100644 --- a/internal/fantasy/object/object.go +++ b/internal/fantasy/object/object.go @@ -4,8 +4,8 @@ package object import ( "context" - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" "reflect" "charm.land/fantasy" @@ -171,7 +171,7 @@ func GenerateWithText( model fantasy.LanguageModel, call fantasy.ObjectCall, ) (*fantasy.ObjectResponse, error) { - jsonSchemaBytes, err := json.Marshal(call.Schema) + jsonSchemaBytes, err := jsonv2.Marshal(call.Schema) if err != nil { return nil, fmt.Errorf("failed to marshal schema: %w", err) } @@ -462,7 +462,7 @@ func StreamWithText( call fantasy.ObjectCall, ) (fantasy.ObjectStreamResponse, error) { jsonSchemaMap := schema.ToMap(call.Schema) - jsonSchemaBytes, err := json.Marshal(jsonSchemaMap) + jsonSchemaBytes, err := jsonv2.Marshal(jsonSchemaMap) if err != nil { return nil, fmt.Errorf("failed to marshal schema: %w", err) } @@ -603,12 +603,12 @@ func StreamWithText( } func unmarshal(obj any, target any) error { - jsonBytes, err := json.Marshal(obj) + jsonBytes, err := jsonv2.Marshal(obj) if err != nil { 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) } diff --git a/internal/fantasy/provider_registry.go b/internal/fantasy/provider_registry.go index efd90446d..d7e44aa0f 100644 --- a/internal/fantasy/provider_registry.go +++ b/internal/fantasy/provider_registry.go @@ -1,15 +1,16 @@ package fantasy import ( - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" "sync" ) // providerDataJSON is the serialized wrapper used by the registry. type providerDataJSON struct { - Type string `json:"type"` - Data json.RawMessage `json:"data"` + Type string `json:"type"` + Data jsontext.Value `json:"data"` } // 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. func unmarshalProviderData(data []byte) (ProviderOptionsData, error) { var pj providerDataJSON - if err := json.Unmarshal(data, &pj); err != nil { + if err := jsonv2.Unmarshal(data, &pj); err != nil { return nil, err } @@ -43,7 +44,7 @@ func unmarshalProviderData(data []byte) (ProviderOptionsData, error) { } // 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) for provider, rawData := range data { 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. -func UnmarshalProviderOptions(data map[string]json.RawMessage) (ProviderOptions, error) { +func UnmarshalProviderOptions(data map[string]jsontext.Value) (ProviderOptions, error) { return unmarshalProviderDataMap(data) } // 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) } @@ -75,14 +76,14 @@ func UnmarshalProviderMetadata(data map[string]json.RawMessage) (ProviderMetadat // return fantasy.MarshalProviderType(TypeProviderOptions, plain(o)) // } func MarshalProviderType[T any](typeID string, data T) ([]byte, error) { - rawData, err := json.Marshal(data) + rawData, err := jsonv2.Marshal(data) if err != nil { return nil, err } - return json.Marshal(providerDataJSON{ + return jsonv2.Marshal(providerDataJSON{ 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 // } func UnmarshalProviderType[T any](data []byte, target *T) error { - return json.Unmarshal(data, target) + return jsonv2.Unmarshal(data, target) } diff --git a/internal/fantasy/providers/anthropic/anthropic.go b/internal/fantasy/providers/anthropic/anthropic.go index e86d8f89f..3292e167d 100644 --- a/internal/fantasy/providers/anthropic/anthropic.go +++ b/internal/fantasy/providers/anthropic/anthropic.go @@ -5,9 +5,9 @@ import ( "cmp" "context" "encoding/base64" - "encoding/json" "errors" "fmt" + jsonv2 "github.com/go-json-experiment/json" "io" "maps" "strings" @@ -777,7 +777,7 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl // in the Anthropic assistant message; the ProviderExecuted flag // is only informational metadata about who ran the tool. 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 } toolUseBlock := anthropic.NewToolUseBlock(toolCall.ToolCallID, inputMap, toolCall.ToolName) diff --git a/internal/fantasy/providers/anthropic/provider_options.go b/internal/fantasy/providers/anthropic/provider_options.go index 7c426f59a..578a626c7 100644 --- a/internal/fantasy/providers/anthropic/provider_options.go +++ b/internal/fantasy/providers/anthropic/provider_options.go @@ -2,7 +2,7 @@ package anthropic import ( - "encoding/json" + jsonv2 "github.com/go-json-experiment/json" "charm.land/fantasy" ) @@ -18,21 +18,21 @@ const ( func init() { fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) { var v ProviderOptions - if err := json.Unmarshal(data, &v); err != nil { + if err := jsonv2.Unmarshal(data, &v); err != nil { return nil, err } return &v, nil }) fantasy.RegisterProviderType(TypeReasoningOptionMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) { var v ReasoningOptionMetadata - if err := json.Unmarshal(data, &v); err != nil { + if err := jsonv2.Unmarshal(data, &v); err != nil { return nil, err } return &v, nil }) fantasy.RegisterProviderType(TypeProviderCacheControl, func(data []byte) (fantasy.ProviderOptionsData, error) { var v ProviderCacheControlOptions - if err := json.Unmarshal(data, &v); err != nil { + if err := jsonv2.Unmarshal(data, &v); err != nil { return nil, err } return &v, nil diff --git a/internal/fantasy/providers/google/google.go b/internal/fantasy/providers/google/google.go index 3ecc945ad..a7d8fe900 100644 --- a/internal/fantasy/providers/google/google.go +++ b/internal/fantasy/providers/google/google.go @@ -3,9 +3,9 @@ package google import ( "cmp" "context" - "encoding/json" "errors" "fmt" + jsonv2 "github.com/go-json-experiment/json" "maps" "net/http" "reflect" @@ -428,7 +428,7 @@ func toGooglePrompt(prompt fantasy.Prompt) (*genai.Content, []*genai.Content, [] } var result map[string]any - err := json.Unmarshal([]byte(toolCall.Input), &result) + err := jsonv2.Unmarshal([]byte(toolCall.Input), &result) if err != nil { continue } @@ -750,7 +750,7 @@ func (g *languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy. return } } - args, err := json.Marshal(part.FunctionCall.Args) + args, err := jsonv2.Marshal(part.FunctionCall.Args) if err != nil { yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeError, @@ -1380,7 +1380,7 @@ func (g languageModel) mapResponse(response *genai.GenerateContentResponse, warn content = append(content, fantasy.TextContent{Text: part.Text}) } case part.FunctionCall != nil: - input, err := json.Marshal(part.FunctionCall.Args) + input, err := jsonv2.Marshal(part.FunctionCall.Args) if err != nil { return nil, err } diff --git a/internal/fantasy/providers/google/provider_options.go b/internal/fantasy/providers/google/provider_options.go index 7a0cd375c..7d44a3acd 100644 --- a/internal/fantasy/providers/google/provider_options.go +++ b/internal/fantasy/providers/google/provider_options.go @@ -2,7 +2,7 @@ package google import ( - "encoding/json" + jsonv2 "github.com/go-json-experiment/json" "charm.land/fantasy" ) @@ -17,14 +17,14 @@ const ( func init() { fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) { var v ProviderOptions - if err := json.Unmarshal(data, &v); err != nil { + if err := jsonv2.Unmarshal(data, &v); err != nil { return nil, err } return &v, nil }) fantasy.RegisterProviderType(TypeReasoningMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) { var v ReasoningMetadata - if err := json.Unmarshal(data, &v); err != nil { + if err := jsonv2.Unmarshal(data, &v); err != nil { return nil, err } return &v, nil diff --git a/internal/fantasy/providers/openai/language_model.go b/internal/fantasy/providers/openai/language_model.go index 9df357ac8..81da51033 100644 --- a/internal/fantasy/providers/openai/language_model.go +++ b/internal/fantasy/providers/openai/language_model.go @@ -2,9 +2,9 @@ package openai import ( "context" - "encoding/json" "errors" "fmt" + jsonv2 "github.com/go-json-experiment/json" "io" "reflect" "strings" @@ -642,7 +642,7 @@ func parseAnnotationsFromDelta(delta openai.ChatCompletionChunkChoiceDelta) []op // Parse the raw JSON to extract annotations 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 } diff --git a/internal/fantasy/providers/openai/openai_test.go b/internal/fantasy/providers/openai/openai_test.go index adc1cf2e8..4ce6f5d2b 100644 --- a/internal/fantasy/providers/openai/openai_test.go +++ b/internal/fantasy/providers/openai/openai_test.go @@ -3,7 +3,6 @@ package openai import ( "context" "encoding/base64" - "encoding/json" "errors" "net/http" "net/http/httptest" @@ -11,6 +10,7 @@ import ( "testing" "charm.land/fantasy" + jsonv2 "github.com/go-json-experiment/json" "github.com/openai/openai-go/v2/packages/param" "github.com/stretchr/testify/require" ) @@ -427,10 +427,10 @@ func TestToOpenAiPrompt_ToolCalls(t *testing.T) { t.Parallel() inputArgs := map[string]any{"foo": "bar123"} - inputJSON, _ := json.Marshal(inputArgs) + inputJSON, _ := jsonv2.Marshal(inputArgs) outputResult := map[string]any{"oof": "321rab"} - outputJSON, _ := json.Marshal(outputResult) + outputJSON, _ := jsonv2.Marshal(outputResult) prompt := fantasy.Prompt{ { @@ -551,7 +551,7 @@ func TestToOpenAiPrompt_AssistantMessages(t *testing.T) { t.Parallel() inputArgs := map[string]any{"query": "test"} - inputJSON, _ := json.Marshal(inputArgs) + inputJSON, _ := jsonv2.Marshal(inputArgs) prompt := fantasy.Prompt{ { @@ -723,7 +723,7 @@ func newMockServer() *mockServer { // Parse request body if r.Body != nil { var body map[string]any - json.NewDecoder(r.Body).Decode(&body) + jsonv2.UnmarshalRead(r.Body, &body) call.body = body } @@ -731,7 +731,7 @@ func newMockServer() *mockServer { // Return mock response w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(ms.response) + jsonv2.MarshalWrite(w, ms.response) })) return ms @@ -2038,7 +2038,7 @@ func newStreamingMockServer() *streamingMockServer { // Parse request body if r.Body != nil { var body map[string]any - json.NewDecoder(r.Body).Decode(&body) + jsonv2.UnmarshalRead(r.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") // 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") // 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") } } @@ -2210,7 +2210,7 @@ func (sms *streamingMockServer) prepareStreamResponse(opts map[string]any) { finishChunk["choices"].([]map[string]any)[0]["logprobs"] = logprobs } - finishData, _ := json.Marshal(finishChunk) + finishData, _ := jsonv2.Marshal(finishChunk) chunks = append(chunks, "data: "+string(finishData)+"\n\n") // Usage chunk @@ -2223,7 +2223,7 @@ func (sms *streamingMockServer) prepareStreamResponse(opts map[string]any) { "choices": []map[string]any{}, "usage": usage, } - usageData, _ := json.Marshal(usageChunk) + usageData, _ := jsonv2.Marshal(usageChunk) chunks = append(chunks, "data: "+string(usageData)+"\n\n") // Done diff --git a/internal/fantasy/providers/openai/provider_options.go b/internal/fantasy/providers/openai/provider_options.go index adb02fbd1..8da6b743b 100644 --- a/internal/fantasy/providers/openai/provider_options.go +++ b/internal/fantasy/providers/openai/provider_options.go @@ -2,7 +2,7 @@ package openai import ( - "encoding/json" + jsonv2 "github.com/go-json-experiment/json" "charm.land/fantasy" "github.com/openai/openai-go/v2" @@ -33,21 +33,21 @@ const ( func init() { fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) { var v ProviderOptions - if err := json.Unmarshal(data, &v); err != nil { + if err := jsonv2.Unmarshal(data, &v); err != nil { return nil, err } return &v, nil }) fantasy.RegisterProviderType(TypeProviderFileOptions, func(data []byte) (fantasy.ProviderOptionsData, error) { var v ProviderFileOptions - if err := json.Unmarshal(data, &v); err != nil { + if err := jsonv2.Unmarshal(data, &v); err != nil { return nil, err } return &v, nil }) fantasy.RegisterProviderType(TypeProviderMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) { var v ProviderMetadata - if err := json.Unmarshal(data, &v); err != nil { + if err := jsonv2.Unmarshal(data, &v); err != nil { return nil, err } return &v, nil diff --git a/internal/fantasy/providers/openai/responses_language_model.go b/internal/fantasy/providers/openai/responses_language_model.go index 39ee8e427..20624d578 100644 --- a/internal/fantasy/providers/openai/responses_language_model.go +++ b/internal/fantasy/providers/openai/responses_language_model.go @@ -3,8 +3,8 @@ package openai import ( "context" "encoding/base64" - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" "reflect" "strings" @@ -479,7 +479,7 @@ func toResponsesPrompt(prompt fantasy.Prompt, systemMessageMode string) (respons continue } - inputJSON, err := json.Marshal(toolCallPart.Input) + inputJSON, err := jsonv2.Marshal(toolCallPart.Input) if err != nil { warnings = append(warnings, fantasy.CallWarning{ Type: fantasy.CallWarningTypeOther, diff --git a/internal/fantasy/providers/openai/responses_options.go b/internal/fantasy/providers/openai/responses_options.go index 7e5b1a4d0..f30368f27 100644 --- a/internal/fantasy/providers/openai/responses_options.go +++ b/internal/fantasy/providers/openai/responses_options.go @@ -2,7 +2,7 @@ package openai import ( - "encoding/json" + jsonv2 "github.com/go-json-experiment/json" "slices" "charm.land/fantasy" @@ -18,14 +18,14 @@ const ( func init() { fantasy.RegisterProviderType(TypeResponsesProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) { var v ResponsesProviderOptions - if err := json.Unmarshal(data, &v); err != nil { + if err := jsonv2.Unmarshal(data, &v); err != nil { return nil, err } return &v, nil }) fantasy.RegisterProviderType(TypeResponsesReasoningMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) { var v ResponsesReasoningMetadata - if err := json.Unmarshal(data, &v); err != nil { + if err := jsonv2.Unmarshal(data, &v); err != nil { return nil, err } return &v, nil diff --git a/internal/fantasy/providers/openaicompat/language_model_hooks.go b/internal/fantasy/providers/openaicompat/language_model_hooks.go index 0aa721d5a..043c44da9 100644 --- a/internal/fantasy/providers/openaicompat/language_model_hooks.go +++ b/internal/fantasy/providers/openaicompat/language_model_hooks.go @@ -2,8 +2,8 @@ package openaicompat import ( "encoding/base64" - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" "strings" "charm.land/fantasy" @@ -50,7 +50,7 @@ func PrepareCallFunc(_ fantasy.LanguageModel, params *openaisdk.ChatCompletionNe func ExtraContentFunc(choice openaisdk.ChatCompletionChoice) []fantasy.Content { var content []fantasy.Content reasoningData := ReasoningData{} - err := json.Unmarshal([]byte(choice.Message.RawJSON()), &reasoningData) + err := jsonv2.Unmarshal([]byte(choice.Message.RawJSON()), &reasoningData) if err != nil { return content } @@ -84,7 +84,7 @@ func StreamExtraFunc(chunk openaisdk.ChatCompletionChunk, yield func(fantasy.Str for inx, choice := range chunk.Choices { reasoningData := ReasoningData{} - err := json.Unmarshal([]byte(choice.Delta.RawJSON()), &reasoningData) + err := jsonv2.Unmarshal([]byte(choice.Delta.RawJSON()), &reasoningData) if err != nil { yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeError, diff --git a/internal/fantasy/providers/openaicompat/provider_options.go b/internal/fantasy/providers/openaicompat/provider_options.go index afb037bf2..63f12de56 100644 --- a/internal/fantasy/providers/openaicompat/provider_options.go +++ b/internal/fantasy/providers/openaicompat/provider_options.go @@ -2,7 +2,7 @@ package openaicompat import ( - "encoding/json" + jsonv2 "github.com/go-json-experiment/json" "charm.land/fantasy" "charm.land/fantasy/providers/openai" @@ -17,7 +17,7 @@ const ( func init() { fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) { var v ProviderOptions - if err := json.Unmarshal(data, &v); err != nil { + if err := jsonv2.Unmarshal(data, &v); err != nil { return nil, err } return &v, nil diff --git a/internal/fantasy/providers/openrouter/language_model_hooks.go b/internal/fantasy/providers/openrouter/language_model_hooks.go index fb69565ac..0f0f69b9f 100644 --- a/internal/fantasy/providers/openrouter/language_model_hooks.go +++ b/internal/fantasy/providers/openrouter/language_model_hooks.go @@ -2,8 +2,8 @@ package openrouter import ( "encoding/base64" - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" "maps" "strings" @@ -74,7 +74,7 @@ func languagePrepareModelCall(_ fantasy.LanguageModel, params *openaisdk.ChatCom func languageModelExtraContent(choice openaisdk.ChatCompletionChoice) []fantasy.Content { content := make([]fantasy.Content, 0) reasoningData := ReasoningData{} - err := json.Unmarshal([]byte(choice.Message.RawJSON()), &reasoningData) + err := jsonv2.Unmarshal([]byte(choice.Message.RawJSON()), &reasoningData) if err != nil { return content } @@ -212,7 +212,7 @@ func languageModelStreamExtra(chunk openaisdk.ChatCompletionChunk, yield func(fa inx := 0 choice := chunk.Choices[inx] reasoningData := ReasoningData{} - err := json.Unmarshal([]byte(choice.Delta.RawJSON()), &reasoningData) + err := jsonv2.Unmarshal([]byte(choice.Delta.RawJSON()), &reasoningData) if err != nil { yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeError, @@ -423,7 +423,7 @@ func languageModelUsage(response openaisdk.ChatCompletion) (fantasy.Usage, fanta openrouterUsage := UsageAccounting{} usage := response.Usage - _ = json.Unmarshal([]byte(usage.RawJSON()), &openrouterUsage) + _ = jsonv2.Unmarshal([]byte(usage.RawJSON()), &openrouterUsage) completionTokenDetails := usage.CompletionTokensDetails promptTokenDetails := usage.PromptTokensDetails @@ -464,7 +464,7 @@ func languageModelStreamUsage(chunk openaisdk.ChatCompletionChunk, _ map[string] } } openrouterUsage := UsageAccounting{} - _ = json.Unmarshal([]byte(usage.RawJSON()), &openrouterUsage) + _ = jsonv2.Unmarshal([]byte(usage.RawJSON()), &openrouterUsage) streamProviderMetadata.Usage = openrouterUsage if p, ok := chunk.JSON.ExtraFields["provider"]; ok { @@ -810,9 +810,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk. Text: reasoningPart.Text, Signature: metadata.Signature, }) - data, _ := json.Marshal(reasoningDetails) + data, _ := jsonv2.Marshal(reasoningDetails) reasoningDetailsMap := []map[string]any{} - _ = json.Unmarshal(data, &reasoningDetailsMap) + _ = jsonv2.Unmarshal(data, &reasoningDetailsMap) assistantMsg.SetExtraFields(map[string]any{ "reasoning_details": reasoningDetailsMap, "reasoning": reasoningPart.Text, @@ -847,9 +847,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk. Data: *metadata.EncryptedContent, ID: metadata.ItemID, }) - data, _ := json.Marshal(reasoningDetails) + data, _ := jsonv2.Marshal(reasoningDetails) reasoningDetailsMap := []map[string]any{} - _ = json.Unmarshal(data, &reasoningDetailsMap) + _ = jsonv2.Unmarshal(data, &reasoningDetailsMap) assistantMsg.SetExtraFields(map[string]any{ "reasoning_details": reasoningDetailsMap, }) @@ -883,9 +883,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk. Data: *metadata.EncryptedContent, ID: metadata.ItemID, }) - data, _ := json.Marshal(reasoningDetails) + data, _ := jsonv2.Marshal(reasoningDetails) reasoningDetailsMap := []map[string]any{} - _ = json.Unmarshal(data, &reasoningDetailsMap) + _ = jsonv2.Unmarshal(data, &reasoningDetailsMap) assistantMsg.SetExtraFields(map[string]any{ "reasoning_details": reasoningDetailsMap, }) @@ -915,9 +915,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk. Data: metadata.Signature, ID: metadata.ToolID, }) - data, _ := json.Marshal(reasoningDetails) + data, _ := jsonv2.Marshal(reasoningDetails) reasoningDetailsMap := []map[string]any{} - _ = json.Unmarshal(data, &reasoningDetailsMap) + _ = jsonv2.Unmarshal(data, &reasoningDetailsMap) assistantMsg.SetExtraFields(map[string]any{ "reasoning_details": reasoningDetailsMap, }) @@ -927,9 +927,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk. Text: reasoningPart.Text, Format: "unknown", }) - data, _ := json.Marshal(reasoningDetails) + data, _ := jsonv2.Marshal(reasoningDetails) reasoningDetailsMap := []map[string]any{} - _ = json.Unmarshal(data, &reasoningDetailsMap) + _ = jsonv2.Unmarshal(data, &reasoningDetailsMap) assistantMsg.SetExtraFields(map[string]any{ "reasoning_details": reasoningDetailsMap, }) diff --git a/internal/fantasy/providers/openrouter/openrouter.go b/internal/fantasy/providers/openrouter/openrouter.go index bd0e700af..d9b12473d 100644 --- a/internal/fantasy/providers/openrouter/openrouter.go +++ b/internal/fantasy/providers/openrouter/openrouter.go @@ -2,7 +2,7 @@ package openrouter import ( - "encoding/json" + jsonv2 "github.com/go-json-experiment/json" "charm.land/fantasy" "charm.land/fantasy/providers/openai" @@ -101,11 +101,11 @@ func WithObjectMode(om fantasy.ObjectMode) Option { func structToMapJSON(s any) (map[string]any, error) { var result map[string]any - jsonBytes, err := json.Marshal(s) + jsonBytes, err := jsonv2.Marshal(s) if err != nil { return nil, err } - err = json.Unmarshal(jsonBytes, &result) + err = jsonv2.Unmarshal(jsonBytes, &result) if err != nil { return nil, err } diff --git a/internal/fantasy/providers/openrouter/provider_options.go b/internal/fantasy/providers/openrouter/provider_options.go index cbba43a31..265c98af8 100644 --- a/internal/fantasy/providers/openrouter/provider_options.go +++ b/internal/fantasy/providers/openrouter/provider_options.go @@ -2,7 +2,7 @@ package openrouter import ( - "encoding/json" + jsonv2 "github.com/go-json-experiment/json" "charm.land/fantasy" ) @@ -29,14 +29,14 @@ const ( func init() { fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) { var v ProviderOptions - if err := json.Unmarshal(data, &v); err != nil { + if err := jsonv2.Unmarshal(data, &v); err != nil { return nil, err } return &v, nil }) fantasy.RegisterProviderType(TypeProviderMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) { var v ProviderMetadata - if err := json.Unmarshal(data, &v); err != nil { + if err := jsonv2.Unmarshal(data, &v); err != nil { return nil, err } return &v, nil @@ -100,53 +100,53 @@ func (m *ProviderMetadata) UnmarshalJSON(data []byte) error { // ReasoningOptions represents reasoning options for OpenRouter. type ReasoningOptions struct { // Whether reasoning is enabled - Enabled *bool `json:"enabled,omitempty"` + Enabled *bool `json:"enabled,omitzero"` // 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 - MaxTokens *int64 `json:"max_tokens,omitempty"` + MaxTokens *int64 `json:"max_tokens,omitzero"` // Reasoning effort level: "low" | "medium" | "high" - Effort *ReasoningEffort `json:"effort,omitempty"` + Effort *ReasoningEffort `json:"effort,omitzero"` } // Provider represents provider routing preferences for OpenRouter. type Provider struct { // 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) - AllowFallbacks *bool `json:"allow_fallbacks,omitempty"` + AllowFallbacks *bool `json:"allow_fallbacks,omitzero"` // 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" - DataCollection *string `json:"data_collection,omitempty"` + DataCollection *string `json:"data_collection,omitzero"` // 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 - Ignore []string `json:"ignore,omitempty"` + Ignore []string `json:"ignore,omitzero"` // 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 *string `json:"sort,omitempty"` + Sort *string `json:"sort,omitzero"` } // ProviderOptions represents additional options for OpenRouter provider. type ProviderOptions struct { - Reasoning *ReasoningOptions `json:"reasoning,omitempty"` - ExtraBody map[string]any `json:"extra_body,omitempty"` - IncludeUsage *bool `json:"include_usage,omitempty"` + Reasoning *ReasoningOptions `json:"reasoning,omitzero"` + ExtraBody map[string]any `json:"extra_body,omitzero"` + IncludeUsage *bool `json:"include_usage,omitzero"` // 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. // 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. // 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. - 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. - User *string `json:"user,omitempty"` + User *string `json:"user,omitzero"` // 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 // 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 // plugin reference. - Plugins []Plugin `json:"plugins,omitempty"` + Plugins []Plugin `json:"plugins,omitzero"` } // WebSearchPlugin configures the OpenRouter web-search plugin. type WebSearchPlugin struct { // 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 string `json:"search_prompt,omitempty"` + SearchPrompt string `json:"search_prompt,omitzero"` } // Plugin represents a single OpenRouter plugin entry. @@ -172,7 +172,7 @@ type Plugin struct { // ID is the plugin identifier (e.g. "web"). ID string `json:"id"` // 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 @@ -209,13 +209,13 @@ func (o *ProviderOptions) UnmarshalJSON(data []byte) error { // ReasoningDetail represents a reasoning detail for OpenRouter. type ReasoningDetail struct { - ID string `json:"id,omitempty"` - Type string `json:"type,omitempty"` - Text string `json:"text,omitempty"` - Data string `json:"data,omitempty"` - Format string `json:"format,omitempty"` - Summary string `json:"summary,omitempty"` - Signature string `json:"signature,omitempty"` + ID string `json:"id,omitzero"` + Type string `json:"type,omitzero"` + Text string `json:"text,omitzero"` + Data string `json:"data,omitzero"` + Format string `json:"format,omitzero"` + Summary string `json:"summary,omitzero"` + Signature string `json:"signature,omitzero"` Index int `json:"index"` } diff --git a/internal/fantasy/providers/vercel/language_model_hooks.go b/internal/fantasy/providers/vercel/language_model_hooks.go index d243e57f6..ce5be15a3 100644 --- a/internal/fantasy/providers/vercel/language_model_hooks.go +++ b/internal/fantasy/providers/vercel/language_model_hooks.go @@ -2,8 +2,8 @@ package vercel import ( "encoding/base64" - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" "maps" "strings" @@ -102,7 +102,7 @@ func languagePrepareModelCall(_ fantasy.LanguageModel, params *openaisdk.ChatCom func languageModelExtraContent(choice openaisdk.ChatCompletionChoice) []fantasy.Content { content := make([]fantasy.Content, 0) reasoningData := ReasoningData{} - err := json.Unmarshal([]byte(choice.Message.RawJSON()), &reasoningData) + err := jsonv2.Unmarshal([]byte(choice.Message.RawJSON()), &reasoningData) if err != nil { return content } @@ -251,7 +251,7 @@ func languageModelStreamExtra(chunk openaisdk.ChatCompletionChunk, yield func(fa inx := 0 choice := chunk.Choices[inx] reasoningData := ReasoningData{} - err := json.Unmarshal([]byte(choice.Delta.RawJSON()), &reasoningData) + err := jsonv2.Unmarshal([]byte(choice.Delta.RawJSON()), &reasoningData) if err != nil { yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeError, @@ -844,9 +844,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk. Text: reasoningPart.Text, Signature: metadata.Signature, }) - data, _ := json.Marshal(reasoningDetails) + data, _ := jsonv2.Marshal(reasoningDetails) reasoningDetailsMap := []map[string]any{} - _ = json.Unmarshal(data, &reasoningDetailsMap) + _ = jsonv2.Unmarshal(data, &reasoningDetailsMap) assistantMsg.SetExtraFields(map[string]any{ "reasoning_details": reasoningDetailsMap, "reasoning": reasoningPart.Text, @@ -880,9 +880,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk. Data: *metadata.EncryptedContent, ID: metadata.ItemID, }) - data, _ := json.Marshal(reasoningDetails) + data, _ := jsonv2.Marshal(reasoningDetails) reasoningDetailsMap := []map[string]any{} - _ = json.Unmarshal(data, &reasoningDetailsMap) + _ = jsonv2.Unmarshal(data, &reasoningDetailsMap) assistantMsg.SetExtraFields(map[string]any{ "reasoning_details": reasoningDetailsMap, }) @@ -911,9 +911,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk. Data: metadata.Signature, ID: metadata.ToolID, }) - data, _ := json.Marshal(reasoningDetails) + data, _ := jsonv2.Marshal(reasoningDetails) reasoningDetailsMap := []map[string]any{} - _ = json.Unmarshal(data, &reasoningDetailsMap) + _ = jsonv2.Unmarshal(data, &reasoningDetailsMap) assistantMsg.SetExtraFields(map[string]any{ "reasoning_details": reasoningDetailsMap, }) @@ -923,9 +923,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk. Text: reasoningPart.Text, Format: "unknown", }) - data, _ := json.Marshal(reasoningDetails) + data, _ := jsonv2.Marshal(reasoningDetails) reasoningDetailsMap := []map[string]any{} - _ = json.Unmarshal(data, &reasoningDetailsMap) + _ = jsonv2.Unmarshal(data, &reasoningDetailsMap) assistantMsg.SetExtraFields(map[string]any{ "reasoning_details": reasoningDetailsMap, }) @@ -1040,11 +1040,11 @@ func hasVisibleUserContent(content []openaisdk.ChatCompletionContentPartUnionPar func structToMapJSON(s any) (map[string]any, error) { var result map[string]any - jsonBytes, err := json.Marshal(s) + jsonBytes, err := jsonv2.Marshal(s) if err != nil { return nil, err } - err = json.Unmarshal(jsonBytes, &result) + err = jsonv2.Unmarshal(jsonBytes, &result) if err != nil { return nil, err } diff --git a/internal/fantasy/providers/vercel/provider_options.go b/internal/fantasy/providers/vercel/provider_options.go index 046ba940d..6016f928f 100644 --- a/internal/fantasy/providers/vercel/provider_options.go +++ b/internal/fantasy/providers/vercel/provider_options.go @@ -2,7 +2,7 @@ package vercel import ( - "encoding/json" + jsonv2 "github.com/go-json-experiment/json" "charm.land/fantasy" ) @@ -17,14 +17,14 @@ const ( func init() { fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) { var v ProviderOptions - if err := json.Unmarshal(data, &v); err != nil { + if err := jsonv2.Unmarshal(data, &v); err != nil { return nil, err } return &v, nil }) fantasy.RegisterProviderType(TypeProviderMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) { var v ProviderMetadata - if err := json.Unmarshal(data, &v); err != nil { + if err := jsonv2.Unmarshal(data, &v); err != nil { return nil, err } return &v, nil @@ -52,58 +52,58 @@ const ( // ReasoningOptions represents reasoning configuration for Vercel AI Gateway. type ReasoningOptions struct { // 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. // Cannot be used with Effort. - MaxTokens *int64 `json:"max_tokens,omitempty"` + MaxTokens *int64 `json:"max_tokens,omitzero"` // Effort controls reasoning effort level. // 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 *bool `json:"exclude,omitempty"` + Exclude *bool `json:"exclude,omitzero"` } // GatewayProviderOptions represents provider routing preferences for Vercel AI Gateway. type GatewayProviderOptions struct { // 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 []string `json:"models,omitempty"` + Models []string `json:"models,omitzero"` } // BYOKCredential represents a single provider credential for BYOK. type BYOKCredential struct { - APIKey string `json:"apiKey,omitempty"` + APIKey string `json:"apiKey,omitzero"` } // BYOKOptions represents Bring Your Own Key options for Vercel AI Gateway. type BYOKOptions struct { - Anthropic map[string][]BYOKCredential `json:"anthropic,omitempty"` - OpenAI map[string][]BYOKCredential `json:"openai,omitempty"` - Vertex map[string][]BYOKCredential `json:"vertex,omitempty"` - Bedrock map[string][]BYOKCredential `json:"bedrock,omitempty"` + Anthropic map[string][]BYOKCredential `json:"anthropic,omitzero"` + OpenAI map[string][]BYOKCredential `json:"openai,omitzero"` + Vertex map[string][]BYOKCredential `json:"vertex,omitzero"` + Bedrock map[string][]BYOKCredential `json:"bedrock,omitzero"` } // ProviderOptions represents additional options for Vercel AI Gateway provider. type ProviderOptions struct { // Reasoning configuration for models that support extended thinking. - Reasoning *ReasoningOptions `json:"reasoning,omitempty"` + Reasoning *ReasoningOptions `json:"reasoning,omitzero"` // ProviderOptions for gateway routing preferences. - ProviderOptions *GatewayProviderOptions `json:"providerOptions,omitempty"` + ProviderOptions *GatewayProviderOptions `json:"providerOptions,omitzero"` // 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 *string `json:"user,omitempty"` + User *string `json:"user,omitzero"` // 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 *bool `json:"logprobs,omitempty"` + LogProbs *bool `json:"logprobs,omitzero"` // 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 *bool `json:"parallel_tool_calls,omitempty"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitzero"` // 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. @@ -128,7 +128,7 @@ func (o *ProviderOptions) UnmarshalJSON(data []byte) error { // ProviderMetadata represents metadata from Vercel AI Gateway provider. type ProviderMetadata struct { - Provider string `json:"provider,omitempty"` + Provider string `json:"provider,omitzero"` } // 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. type ReasoningDetail struct { - ID string `json:"id,omitempty"` - Type string `json:"type,omitempty"` - Text string `json:"text,omitempty"` - Data string `json:"data,omitempty"` - Format string `json:"format,omitempty"` - Summary string `json:"summary,omitempty"` - Signature string `json:"signature,omitempty"` + ID string `json:"id,omitzero"` + Type string `json:"type,omitzero"` + Text string `json:"text,omitzero"` + Data string `json:"data,omitzero"` + Format string `json:"format,omitzero"` + Summary string `json:"summary,omitzero"` + Signature string `json:"signature,omitzero"` Index int `json:"index"` } // ReasoningData represents reasoning data from Vercel AI Gateway response. type ReasoningData struct { - Reasoning string `json:"reasoning,omitempty"` - ReasoningDetails []ReasoningDetail `json:"reasoning_details,omitempty"` + Reasoning string `json:"reasoning,omitzero"` + ReasoningDetails []ReasoningDetail `json:"reasoning_details,omitzero"` } // ReasoningEffortOption creates a pointer to a ReasoningEffort value. diff --git a/internal/fantasy/providertests/provider_registry_test.go b/internal/fantasy/providertests/provider_registry_test.go index e798e4894..2200af4bd 100644 --- a/internal/fantasy/providertests/provider_registry_test.go +++ b/internal/fantasy/providertests/provider_registry_test.go @@ -1,7 +1,7 @@ package providertests import ( - "encoding/json" + jsonv2 "github.com/go-json-experiment/json" "testing" "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) var raw struct { 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] require.True(t, ok) @@ -41,7 +41,7 @@ func TestProviderRegistry_Serialization_OpenAIOptions(t *testing.T) { require.Equal(t, "tester", inner["user"]) var decoded fantasy.Message - require.NoError(t, json.Unmarshal(data, &decoded)) + require.NoError(t, jsonv2.Unmarshal(data, &decoded)) got, ok := decoded.ProviderOptions[openai.Name] 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) // JSON should include the typed wrapper with constant TypeResponsesProviderOptions var raw struct { 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] 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 var decoded fantasy.Message - require.NoError(t, json.Unmarshal(data, &decoded)) + require.NoError(t, jsonv2.Unmarshal(data, &decoded)) got := decoded.ProviderOptions[openai.Name] reqOpts, ok := got.(*openai.ResponsesProviderOptions) 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) // 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"` } `json:"content"` } - require.NoError(t, json.Unmarshal(data, &raw)) + require.NoError(t, jsonv2.Unmarshal(data, &raw)) require.Greater(t, len(raw.Content), 0) tc := raw.Content[0] pm, ok := tc.Data["provider_metadata"].(map[string]any) @@ -133,7 +133,7 @@ func TestProviderRegistry_Serialization_OpenAIResponsesReasoningMetadata(t *test // Unmarshal back 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 val, ok := pmDecoded[openai.Name] 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) var decoded fantasy.Message - require.NoError(t, json.Unmarshal(data, &decoded)) + require.NoError(t, jsonv2.Unmarshal(data, &decoded)) got, ok := decoded.ProviderOptions[anthropic.Name] 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) var decoded fantasy.Message - require.NoError(t, json.Unmarshal(data, &decoded)) + require.NoError(t, jsonv2.Unmarshal(data, &decoded)) got, ok := decoded.ProviderOptions[google.Name] 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) var decoded fantasy.Message - require.NoError(t, json.Unmarshal(data, &decoded)) + require.NoError(t, jsonv2.Unmarshal(data, &decoded)) got, ok := decoded.ProviderOptions[openrouter.Name] 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) var decoded fantasy.Message - require.NoError(t, json.Unmarshal(data, &decoded)) + require.NoError(t, jsonv2.Unmarshal(data, &decoded)) got, ok := decoded.ProviderOptions[openaicompat.Name] 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) var decoded fantasy.Message - require.NoError(t, json.Unmarshal(data, &decoded)) + require.NoError(t, jsonv2.Unmarshal(data, &decoded)) // Check OpenAI options openaiOpt, ok := decoded.ProviderOptions[openai.Name] @@ -312,7 +312,7 @@ func TestProviderRegistry_ErrorHandling(t *testing.T) { }` var msg fantasy.Message - err := json.Unmarshal([]byte(invalidJSON), &msg) + err := jsonv2.Unmarshal([]byte(invalidJSON), &msg) require.Error(t, err) require.Contains(t, err.Error(), "unknown provider data type") }) @@ -327,7 +327,7 @@ func TestProviderRegistry_ErrorHandling(t *testing.T) { }` var msg fantasy.Message - err := json.Unmarshal([]byte(invalidJSON), &msg) + err := jsonv2.Unmarshal([]byte(invalidJSON), &msg) require.Error(t, err) }) } @@ -364,11 +364,11 @@ func TestProviderRegistry_AllTypesRegistered(t *testing.T) { } // Marshal and unmarshal - data, err := json.Marshal(msg) + data, err := jsonv2.Marshal(msg) require.NoError(t, err) var decoded fantasy.Message - err = json.Unmarshal(data, &decoded) + err = jsonv2.Unmarshal(data, &decoded) require.NoError(t, err) // Verify the provider options exist @@ -404,11 +404,11 @@ func TestProviderRegistry_AllTypesRegistered(t *testing.T) { } // Marshal and unmarshal - data, err := json.Marshal(resp) + data, err := jsonv2.Marshal(resp) require.NoError(t, err) var decoded fantasy.Response - err = json.Unmarshal(data, &decoded) + err = jsonv2.Unmarshal(data, &decoded) require.NoError(t, err) // Verify the provider metadata exists diff --git a/internal/fantasy/schema/schema.go b/internal/fantasy/schema/schema.go index 883958315..24de55703 100644 --- a/internal/fantasy/schema/schema.go +++ b/internal/fantasy/schema/schema.go @@ -4,8 +4,8 @@ package schema import ( "context" - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" "reflect" "slices" "strings" @@ -259,7 +259,7 @@ func ParsePartialJSON(text string) (any, ParseState, error) { } 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 } @@ -268,7 +268,7 @@ func ParsePartialJSON(text string) (any, ParseState, error) { 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) } @@ -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 // than the typed Schema struct. func ValidateAgainstSchemaMap(obj any, schemaMap map[string]any) error { - schemaBytes, err := json.Marshal(schemaMap) + schemaBytes, err := jsonv2.Marshal(schemaMap) if err != nil { 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 { - jsonSchemaBytes, err := json.Marshal(schema) + jsonSchemaBytes, err := jsonv2.Marshal(schema) if err != nil { return fmt.Errorf("failed to marshal schema: %w", err) } diff --git a/internal/fantasy/tool.go b/internal/fantasy/tool.go index be146ef3f..33632b75a 100644 --- a/internal/fantasy/tool.go +++ b/internal/fantasy/tool.go @@ -2,8 +2,8 @@ package fantasy import ( "context" - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" "reflect" "charm.land/fantasy/schema" @@ -78,7 +78,7 @@ func NewMediaResponse(data []byte, mediaType string) ToolResponse { // WithResponseMetadata adds metadata to a response. func WithResponseMetadata(response ToolResponse, metadata any) ToolResponse { if metadata != nil { - metadataBytes, err := json.Marshal(metadata) + metadataBytes, err := jsonv2.Marshal(metadata) if err != nil { return response } @@ -167,7 +167,7 @@ func (w *funcToolWrapper[TInput]) Info() ToolInfo { func (w *funcToolWrapper[TInput]) Run(ctx context.Context, params ToolCall) (ToolResponse, error) { 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 } diff --git a/internal/fantasy/tool_dag.go b/internal/fantasy/tool_dag.go index e548fd9ef..0e3716f7f 100644 --- a/internal/fantasy/tool_dag.go +++ b/internal/fantasy/tool_dag.go @@ -1,9 +1,9 @@ package fantasy import ( - "encoding/json" "errors" "fmt" + jsonv2 "github.com/go-json-experiment/json" "strings" ) @@ -91,7 +91,7 @@ func extractToolDependencies(input string) ([]string, error) { } 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. return nil, nil } diff --git a/internal/fantasy/tool_runtime_dag.go b/internal/fantasy/tool_runtime_dag.go index 2eb04a0b5..e90236892 100644 --- a/internal/fantasy/tool_runtime_dag.go +++ b/internal/fantasy/tool_runtime_dag.go @@ -2,9 +2,9 @@ package fantasy import ( "context" - "encoding/json" "errors" "fmt" + jsonv2 "github.com/go-json-experiment/json" "sort" "strconv" "strings" @@ -233,7 +233,7 @@ func resolveToolRefsInInput(input string, results map[string]ToolResultContent) } 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. return input, nil } @@ -242,7 +242,7 @@ func resolveToolRefsInInput(input string, results map[string]ToolResultContent) if err != nil { return "", err } - b, err := json.Marshal(updated) + b, err := jsonv2.Marshal(updated) if err != nil { return "", err } @@ -328,7 +328,7 @@ func resolveToolRefValue(ref string, results map[string]ToolResultContent) (any, // Path resolution: interpret base as JSON and walk. 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) } diff --git a/pkg/agent/conversations/store.go b/pkg/agent/conversations/store.go index 8061c301f..550740f95 100644 --- a/pkg/agent/conversations/store.go +++ b/pkg/agent/conversations/store.go @@ -5,7 +5,7 @@ package conversations import ( "context" - "encoding/json" + jsonv2 "github.com/go-json-experiment/json" "strings" "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 } - metaJSON, _ := json.Marshal(p.Metadata) + metaJSON, _ := jsonv2.Marshal(p.Metadata) // Best-effort revision record — a failure here does not abort the edit. _, _ = s.q.AddAgentMessageRevision(ctx, sqlc.AddAgentMessageRevisionParams{ @@ -174,7 +174,7 @@ func (s *Store) ForkFromCheckpoint(ctx context.Context, p ForkFromCheckpointPara Messages []msgSnapshot `json:"messages"` } var snap snapshot - _ = json.Unmarshal(runState.SnapshotJson, &snap) + _ = jsonv2.Unmarshal([]byte(runState.SnapshotJson), &snap) conv, err := s.q.CreateAgentConversation(ctx, sqlc.CreateAgentConversationParams{ ID: ids.New(), @@ -188,7 +188,7 @@ func (s *Store) ForkFromCheckpoint(ctx context.Context, p ForkFromCheckpointPara "checkpoint_name": cpName, "run_state_id": cp.RunStateID.String(), } - forkMetaJSON, _ := json.Marshal(forkMeta) + forkMetaJSON, _ := jsonv2.Marshal(forkMeta) _, _ = s.q.CreateAgentConversationFork(ctx, sqlc.CreateAgentConversationForkParams{ ID: ids.New(), @@ -209,7 +209,7 @@ func (s *Store) ForkFromCheckpoint(ctx context.Context, p ForkFromCheckpointPara "checkpoint_name": cpName, "run_state_id": cp.RunStateID.String(), } - seedMetaJSON, _ := json.Marshal(seedMeta) + seedMetaJSON, _ := jsonv2.Marshal(seedMeta) for _, m := range msgs { 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"} - metaJSON, _ := json.Marshal(meta) + metaJSON, _ := jsonv2.Marshal(meta) _, _ = s.q.CreateAgentConversationLink(ctx, sqlc.CreateAgentConversationLinkParams{ ID: ids.New(), @@ -321,7 +321,7 @@ type AncestryParams struct { // AncestryResult is the structured result of an Ancestry query. type AncestryResult struct { 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"` Links []sqlc.AgentConversationLink `json:"links"` } @@ -434,7 +434,7 @@ type GraphParams struct { // GraphNode is a single conversation node in the fork/link graph. type GraphNode struct { ID string `json:"id"` - Title *string `json:"title,omitempty"` + Title *string `json:"title,omitzero"` } // GraphEdge is a directed edge between two conversation nodes. @@ -444,9 +444,9 @@ type GraphEdge struct { From string `json:"from"` To string `json:"to"` // 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 *string `json:"kind,omitempty"` + Kind *string `json:"kind,omitzero"` } // GraphResult is the full fork/link graph rooted at a conversation. diff --git a/pkg/agent/memgpt_tool.go b/pkg/agent/memgpt_tool.go index c4b97200c..89178f222 100644 --- a/pkg/agent/memgpt_tool.go +++ b/pkg/agent/memgpt_tool.go @@ -7,7 +7,7 @@ package agent import ( "context" - "encoding/json" + jsonv2 "github.com/go-json-experiment/json" memstore "github.com/sipeed/picoclaw/pkg/memory/store" "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 { // Marshal the args back to JSON for the inner MemoryTool.Execute() - input, err := json.Marshal(args) + input, err := jsonv2.Marshal(args) if err != nil { return tools.ErrorResult("invalid arguments: " + err.Error()) } diff --git a/pkg/agent/mentions/store.go b/pkg/agent/mentions/store.go index 872f14823..c6dee2ddb 100644 --- a/pkg/agent/mentions/store.go +++ b/pkg/agent/mentions/store.go @@ -5,7 +5,7 @@ package mentions import ( "context" - "encoding/json" + jsonv2 "github.com/go-json-experiment/json" "strings" "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) } - metaJSON, _ := json.Marshal(p.Metadata) + metaJSON, _ := jsonv2.Marshal(p.Metadata) return s.q.AddAgentMention(ctx, sqlc.AddAgentMentionParams{ ID: ids.New(), diff --git a/pkg/agent/offloading_tool_runtime.go b/pkg/agent/offloading_tool_runtime.go index c19e6e0d8..59f23215d 100644 --- a/pkg/agent/offloading_tool_runtime.go +++ b/pkg/agent/offloading_tool_runtime.go @@ -2,8 +2,8 @@ package agent import ( "context" - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" "strconv" "strings" @@ -91,7 +91,7 @@ func (r OffloadingToolRuntime) Execute(ctx context.Context, tools []fantasy.Agen fullKey := toolResultFullKey(r.ConversationID, r.RunID, stepIndex, tc.ToolCallID) payload, payloadType, payloadText := toolResultPayload(res) - b, marshalErr := json.Marshal(payload) + b, marshalErr := jsonv2.Marshal(payload) if marshalErr != nil { b = []byte(`{"error":"failed to marshal tool result payload"}`) payloadType = "error" @@ -134,7 +134,7 @@ func (r OffloadingToolRuntime) Execute(ctx context.Context, tools []fantasy.Agen "result_type": payloadType, "step_index": stepIndex, } - metaJSON, _ := json.Marshal(meta) + metaJSON, _ := jsonv2.Marshal(meta) var previewPtr *string if strings.TrimSpace(preview) != "" { diff --git a/pkg/agent/state_store.go b/pkg/agent/state_store.go index d7d716ecd..e407e0171 100644 --- a/pkg/agent/state_store.go +++ b/pkg/agent/state_store.go @@ -6,6 +6,8 @@ import ( "strings" "time" + jsonv2 "github.com/go-json-experiment/json" + "charm.land/fantasy" "github.com/sipeed/picoclaw/pkg/ids" "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(`{}`) if meta != nil { - if b, err := json.Marshal(meta); err == nil { - metaJSON = b + if b, err := jsonv2.Marshal(meta); err == nil { + metaJSON = json.RawMessage(b) } } @@ -75,8 +77,8 @@ func (s *StateStore) AddRunState(ctx context.Context, runID ids.UUID, stepIndex snapJSON := json.RawMessage(`{}`) if snapshot != nil { - if b, err := json.Marshal(snapshot); err == nil { - snapJSON = b + if b, err := jsonv2.Marshal(snapshot); err == nil { + snapJSON = json.RawMessage(b) } } @@ -99,8 +101,8 @@ func (s *StateStore) AddTransition(ctx context.Context, runID ids.UUID, t fantas metaJSON := json.RawMessage(`{}`) if t.Meta != nil { - if b, err := json.Marshal(t.Meta); err == nil { - metaJSON = b + if b, err := jsonv2.Marshal(t.Meta); err == nil { + metaJSON = json.RawMessage(b) } } @@ -152,8 +154,8 @@ func (s *CheckpointStore) CreateCheckpoint(ctx context.Context, conversationID i metaJSON := json.RawMessage(`{}`) if meta != nil { - if b, err := json.Marshal(meta); err == nil { - metaJSON = b + if b, err := jsonv2.Marshal(meta); err == nil { + metaJSON = json.RawMessage(b) } } diff --git a/pkg/agent/threads/store.go b/pkg/agent/threads/store.go index 44362dfd4..646d319cb 100644 --- a/pkg/agent/threads/store.go +++ b/pkg/agent/threads/store.go @@ -6,7 +6,7 @@ package threads import ( "context" - "encoding/json" + jsonv2 "github.com/go-json-experiment/json" "strings" "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) } - metaJSON, _ := json.Marshal(p.Metadata) + metaJSON, _ := jsonv2.Marshal(p.Metadata) return s.q.CreateAgentThread(ctx, sqlc.CreateAgentThreadParams{ 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") } - metaJSON, _ := json.Marshal(p.Metadata) + metaJSON, _ := jsonv2.Marshal(p.Metadata) return s.q.AddAgentThreadMessage(ctx, sqlc.AddAgentThreadMessageParams{ ID: ids.New(), diff --git a/pkg/agent/tool_result_search.go b/pkg/agent/tool_result_search.go index cfc75c67d..1ee1b2556 100644 --- a/pkg/agent/tool_result_search.go +++ b/pkg/agent/tool_result_search.go @@ -2,10 +2,12 @@ package agent import ( "context" - "encoding/json" "fmt" "strings" + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" + "charm.land/fantasy" "github.com/sipeed/picoclaw/pkg/ids" "github.com/sipeed/picoclaw/pkg/memory/sqlc" @@ -13,23 +15,23 @@ import ( ) type ToolResultSearchView struct { - StartLine int `json:"start_line,omitempty" description:"Optional. 1-indexed start line (inclusive)."` - EndLine int `json:"end_line,omitempty" description:"Optional. 1-indexed end line (inclusive)."` - MaxLines int `json:"max_lines,omitempty" description:"Optional. Default 30, max 200."` - StartChunk int `json:"start_chunk,omitempty" description:"Optional. 0-indexed start chunk (inclusive)."` - EndChunk int `json:"end_chunk,omitempty" description:"Optional. 0-indexed end chunk (inclusive)."` - MaxChunks int `json:"max_chunks,omitempty" description:"Optional. Default 3, max 20."` + StartLine int `json:"start_line,omitzero" description:"Optional. 1-indexed start line (inclusive)."` + EndLine int `json:"end_line,omitzero" description:"Optional. 1-indexed end line (inclusive)."` + MaxLines int `json:"max_lines,omitzero" description:"Optional. Default 30, max 200."` + StartChunk int `json:"start_chunk,omitzero" description:"Optional. 0-indexed start chunk (inclusive)."` + EndChunk int `json:"end_chunk,omitzero" description:"Optional. 0-indexed end chunk (inclusive)."` + MaxChunks int `json:"max_chunks,omitzero" description:"Optional. Default 3, max 20."` } type ToolResultSearchInput struct { - ConversationID string `json:"conversation_id,omitempty" description:"Optional. Agent conversation UUID."` - RunID string `json:"run_id,omitempty" description:"Optional. Agent run UUID."` - ToolCallID string `json:"tool_call_id,omitempty" description:"Optional. Tool call id to fetch (requires run_id)."` - ToolName string `json:"tool_name,omitempty" description:"Optional. Filter by tool name."` - Query string `json:"query,omitempty" description:"Optional. Case-insensitive substring match on tool_name/tool_call_id/summary."` + ConversationID string `json:"conversation_id,omitzero" description:"Optional. Agent conversation UUID."` + RunID string `json:"run_id,omitzero" description:"Optional. Agent run UUID."` + ToolCallID string `json:"tool_call_id,omitzero" description:"Optional. Tool call id to fetch (requires run_id)."` + ToolName string `json:"tool_name,omitzero" description:"Optional. Filter by tool name."` + 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."` - View *ToolResultSearchView `json:"view,omitempty" description:"Optional. File view range for each result."` + Limit int `json:"limit,omitzero" description:"Optional. Default 5, max 50."` + View *ToolResultSearchView `json:"view,omitzero" description:"Optional. File view range for each result."` } // 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) type item struct { - ID string `json:"id"` - RunID string `json:"run_id"` - StepIndex int64 `json:"step_index"` - ToolCallID string `json:"tool_call_id"` - ToolName string `json:"tool_name"` - Preview *string `json:"preview,omitempty"` - FullKey string `json:"full_key"` - ChunkCount int64 `json:"chunk_count"` - View string `json:"view"` - ViewRange map[string]int `json:"view_range"` - Metadata json.RawMessage `json:"metadata_json"` + ID string `json:"id"` + RunID string `json:"run_id"` + StepIndex int64 `json:"step_index"` + ToolCallID string `json:"tool_call_id"` + ToolName string `json:"tool_name"` + Preview *string `json:"preview,omitzero"` + FullKey string `json:"full_key"` + ChunkCount int64 `json:"chunk_count"` + View string `json:"view"` + ViewRange map[string]int `json:"view_range"` + Metadata jsontext.Value `json:"metadata_json"` // sqlc gives json.RawMessage; both are []byte } out := struct { @@ -131,11 +133,11 @@ func NewToolResultSearchTool(q *sqlc.Queries, kv KVDelegate) fantasy.AgentTool { ChunkCount: r.ChunkCount, View: sel, ViewRange: viewRange, - Metadata: r.MetadataJson, + Metadata: jsontext.Value(r.MetadataJson), }) } - b, _ := json.Marshal(out) + b, _ := jsonv2.Marshal(out) return fantasy.NewTextResponse(string(b)), nil }, ) diff --git a/pkg/agent/tool_result_search_test.go b/pkg/agent/tool_result_search_test.go index 733727d2d..bfd9f7fae 100644 --- a/pkg/agent/tool_result_search_test.go +++ b/pkg/agent/tool_result_search_test.go @@ -2,7 +2,7 @@ package agent_test import ( "context" - "encoding/json" + jsonv2 "github.com/go-json-experiment/json" "strings" "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) 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 } func marshalInput(t *testing.T, v any) string { t.Helper() - b, err := json.Marshal(v) + b, err := jsonv2.Marshal(v) require.NoError(t, err) return string(b) } diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go index dcd91bebd..00f185521 100644 --- a/pkg/auth/oauth.go +++ b/pkg/auth/oauth.go @@ -5,7 +5,6 @@ import ( "crypto/rand" "encoding/base64" "encoding/hex" - "encoding/json" "fmt" "io" "net" @@ -16,6 +15,9 @@ import ( "strconv" "strings" "time" + + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" ) type OAuthProviderConfig struct { @@ -128,12 +130,12 @@ type deviceCodeResponse struct { func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) { var raw struct { - DeviceAuthID string `json:"device_auth_id"` - UserCode string `json:"user_code"` - Interval json.RawMessage `json:"interval"` + DeviceAuthID string `json:"device_auth_id"` + UserCode string `json:"user_code"` + Interval jsontext.Value `json:"interval"` } - if err := json.Unmarshal(body, &raw); err != nil { + if err := jsonv2.Unmarshal(body, &raw); err != nil { return deviceCodeResponse{}, err } @@ -149,18 +151,18 @@ func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) { }, nil } -func parseFlexibleInt(raw json.RawMessage) (int, error) { +func parseFlexibleInt(raw jsontext.Value) (int, error) { if len(raw) == 0 || string(raw) == "null" { return 0, nil } var interval int - if err := json.Unmarshal(raw, &interval); err == nil { + if err := jsonv2.Unmarshal(raw, &interval); err == nil { return interval, nil } var intervalStr string - if err := json.Unmarshal(raw, &intervalStr); err == nil { + if err := jsonv2.Unmarshal(raw, &intervalStr); err == nil { intervalStr = strings.TrimSpace(intervalStr) if intervalStr == "" { return 0, nil @@ -172,7 +174,7 @@ func parseFlexibleInt(raw json.RawMessage) (int, error) { } func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) { - reqBody, _ := json.Marshal(map[string]string{ + reqBody, _ := jsonv2.Marshal(map[string]string{ "client_id": cfg.ClientID, }) @@ -224,7 +226,7 @@ func LoginDeviceCode(cfg OAuthProviderConfig) (*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, "user_code": userCode, }) @@ -250,7 +252,7 @@ func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*Au CodeChallenge string `json:"code_challenge"` CodeVerifier string `json:"code_verifier"` } - if err := json.Unmarshal(body, &tokenResp); err != nil { + if err := jsonv2.Unmarshal(body, &tokenResp); err != nil { return nil, err } @@ -349,7 +351,7 @@ func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) { ExpiresIn int `json:"expires_in"` 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) } @@ -435,7 +437,7 @@ func parseJWTClaims(token string) (map[string]interface{}, error) { } var claims map[string]interface{} - if err := json.Unmarshal(decoded, &claims); err != nil { + if err := jsonv2.Unmarshal(decoded, &claims); err != nil { return nil, err } diff --git a/pkg/auth/oauth_test.go b/pkg/auth/oauth_test.go index 5deb17805..c3eb0e9dc 100644 --- a/pkg/auth/oauth_test.go +++ b/pkg/auth/oauth_test.go @@ -2,19 +2,20 @@ package auth import ( "encoding/base64" - "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "testing" + + jsonv2 "github.com/go-json-experiment/json" ) func makeJWTForClaims(t *testing.T, claims map[string]interface{}) string { t.Helper() header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) - payloadJSON, err := json.Marshal(claims) + payloadJSON, err := jsonv2.Marshal(claims) if err != nil { t.Fatalf("marshal claims: %v", err) } @@ -95,7 +96,7 @@ func TestParseTokenResponse(t *testing.T) { "expires_in": 3600, "id_token": "test-id-token", } - body, _ := json.Marshal(resp) + body, _ := jsonv2.Marshal(resp) cred, err := parseTokenResponse(body, "openai") if err != nil { @@ -127,7 +128,7 @@ func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) { "expires_in": 3600, "id_token": idToken, } - body, _ := json.Marshal(resp) + body, _ := jsonv2.Marshal(resp) cred, err := parseTokenResponse(body, "openai") if err != nil { @@ -166,7 +167,7 @@ func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) { "expires_in": 3600, "id_token": idToken, } - body, _ := json.Marshal(resp) + body, _ := jsonv2.Marshal(resp) cred, err := parseTokenResponse(body, "openai") if err != nil { @@ -206,7 +207,7 @@ func TestExchangeCodeForTokens(t *testing.T) { "refresh_token": "mock-refresh-token", "expires_in": 3600, } - json.NewEncoder(w).Encode(resp) + jsonv2.MarshalWrite(w, resp) })) defer server.Close() @@ -245,7 +246,7 @@ func TestRefreshAccessToken(t *testing.T) { "refresh_token": "refreshed-refresh-token", "expires_in": 3600, } - json.NewEncoder(w).Encode(resp) + jsonv2.MarshalWrite(w, resp) })) defer server.Close() @@ -294,7 +295,7 @@ func TestRefreshAccessTokenPreservesRefreshAndAccountID(t *testing.T) { "access_token": "new-access-token-only", "expires_in": 3600, } - json.NewEncoder(w).Encode(resp) + jsonv2.MarshalWrite(w, resp) })) defer server.Close() diff --git a/pkg/auth/store.go b/pkg/auth/store.go index 20724929a..d0aa56154 100644 --- a/pkg/auth/store.go +++ b/pkg/auth/store.go @@ -1,17 +1,19 @@ package auth import ( - "encoding/json" "os" "path/filepath" "time" + + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" ) type AuthCredential struct { AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token,omitempty"` - AccountID string `json:"account_id,omitempty"` - ExpiresAt time.Time `json:"expires_at,omitempty"` + RefreshToken string `json:"refresh_token,omitzero"` + AccountID string `json:"account_id,omitzero"` + ExpiresAt time.Time `json:"expires_at,omitzero"` Provider string `json:"provider"` AuthMethod string `json:"auth_method"` } @@ -50,7 +52,7 @@ func LoadStore() (*AuthStore, error) { } var store AuthStore - if err := json.Unmarshal(data, &store); err != nil { + if err := jsonv2.Unmarshal(data, &store); err != nil { return nil, err } if store.Credentials == nil { @@ -66,7 +68,7 @@ func SaveStore(store *AuthStore) error { return err } - data, err := json.MarshalIndent(store, "", " ") + data, err := jsonv2.Marshal(store, jsontext.WithIndent(" ")) if err != nil { return err } diff --git a/pkg/channels/feishu_64.go b/pkg/channels/feishu_64.go index 39dc40ac1..52f310996 100644 --- a/pkg/channels/feishu_64.go +++ b/pkg/channels/feishu_64.go @@ -4,11 +4,12 @@ package channels import ( "context" - "encoding/json" "fmt" "sync" "time" + jsonv2 "github.com/go-json-experiment/json" + lark "github.com/larksuite/oapi-sdk-go/v3" larkdispatcher "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher" 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") } - payload, err := json.Marshal(map[string]string{"text": msg.Content}) + payload, err := jsonv2.Marshal(map[string]string{"text": msg.Content}) if err != nil { return fmt.Errorf("failed to marshal feishu content: %w", err) } @@ -202,7 +203,7 @@ func extractFeishuMessageContent(message *larkim.EventMessage) string { var textPayload struct { 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 } } diff --git a/pkg/channels/line.go b/pkg/channels/line.go index ffb5533e8..2703aeea8 100644 --- a/pkg/channels/line.go +++ b/pkg/channels/line.go @@ -6,7 +6,6 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/base64" - "encoding/json" "fmt" "io" "net/http" @@ -15,6 +14,9 @@ import ( "sync" "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/config" "github.com/sipeed/picoclaw/pkg/logger" @@ -140,7 +142,7 @@ func (c *LINEChannel) fetchBotInfo() error { BasicID string `json:"basicId"` 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 } @@ -199,7 +201,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) { var payload struct { 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{}{ "error": err.Error(), }) @@ -230,11 +232,11 @@ func (c *LINEChannel) verifySignature(body []byte, signature string) bool { // LINE webhook event types type lineEvent struct { - Type string `json:"type"` - ReplyToken string `json:"replyToken"` - Source lineSource `json:"source"` - Message json.RawMessage `json:"message"` - Timestamp int64 `json:"timestamp"` + Type string `json:"type"` + ReplyToken string `json:"replyToken"` + Source lineSource `json:"source"` + Message jsontext.Value `json:"message"` + Timestamp int64 `json:"timestamp"` } type lineSource struct { @@ -277,7 +279,7 @@ func (c *LINEChannel) processEvent(event lineEvent) { isGroup := event.Source.Type == "group" || event.Source.Type == "room" 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{}{ "error": err.Error(), }) @@ -558,7 +560,7 @@ func (c *LINEChannel) sendLoading(chatID string) { // callAPI makes an authenticated POST request to the LINE API. 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 { return fmt.Errorf("failed to marshal payload: %w", err) } diff --git a/pkg/channels/maixcam.go b/pkg/channels/maixcam.go index 27d66997a..f1b786b95 100644 --- a/pkg/channels/maixcam.go +++ b/pkg/channels/maixcam.go @@ -2,11 +2,12 @@ package channels import ( "context" - "encoding/json" "fmt" "net" "sync" + jsonv2 "github.com/go-json-experiment/json" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" "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") }() - decoder := json.NewDecoder(conn) - for { select { case <-ctx.Done(): return default: var msg MaixCamMessage - if err := decoder.Decode(&msg); err != nil { + if err := jsonv2.UnmarshalRead(conn, &msg); err != nil { if err.Error() != "EOF" { logger.ErrorCF("maixcam", "Failed to decode message", map[string]interface{}{ "error": err.Error(), @@ -221,7 +220,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro "chat_id": msg.ChatID, } - data, err := json.Marshal(response) + data, err := jsonv2.Marshal(response) if err != nil { return fmt.Errorf("failed to marshal response: %w", err) } diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go index 5d97fab9c..9e0f14e8a 100644 --- a/pkg/channels/onebot.go +++ b/pkg/channels/onebot.go @@ -2,13 +2,14 @@ package channels import ( "context" - "encoding/json" "fmt" "strconv" "strings" "sync" "time" + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" "github.com/gorilla/websocket" "github.com/sipeed/picoclaw/pkg/bus" @@ -31,21 +32,21 @@ type OneBotChannel struct { } type oneBotRawEvent struct { - PostType string `json:"post_type"` - MessageType string `json:"message_type"` - SubType string `json:"sub_type"` - MessageID json.RawMessage `json:"message_id"` - UserID json.RawMessage `json:"user_id"` - GroupID json.RawMessage `json:"group_id"` - RawMessage string `json:"raw_message"` - Message json.RawMessage `json:"message"` - Sender json.RawMessage `json:"sender"` - SelfID json.RawMessage `json:"self_id"` - Time json.RawMessage `json:"time"` - MetaEventType string `json:"meta_event_type"` - Echo string `json:"echo"` - RetCode json.RawMessage `json:"retcode"` - Status BotStatus `json:"status"` + PostType string `json:"post_type"` + MessageType string `json:"message_type"` + SubType string `json:"sub_type"` + MessageID jsontext.Value `json:"message_id"` + UserID jsontext.Value `json:"user_id"` + GroupID jsontext.Value `json:"group_id"` + RawMessage string `json:"raw_message"` + Message jsontext.Value `json:"message"` + Sender jsontext.Value `json:"sender"` + SelfID jsontext.Value `json:"self_id"` + Time jsontext.Value `json:"time"` + MetaEventType string `json:"meta_event_type"` + Echo string `json:"echo"` + RetCode jsontext.Value `json:"retcode"` + Status BotStatus `json:"status"` } type BotStatus struct { @@ -54,9 +55,9 @@ type BotStatus struct { } type oneBotSender struct { - UserID json.RawMessage `json:"user_id"` - Nickname string `json:"nickname"` - Card string `json:"card"` + UserID jsontext.Value `json:"user_id"` + Nickname string `json:"nickname"` + Card string `json:"card"` } type oneBotEvent struct { @@ -78,7 +79,7 @@ type oneBotEvent struct { type oneBotAPIRequest struct { Action string `json:"action"` Params interface{} `json:"params"` - Echo string `json:"echo,omitempty"` + Echo string `json:"echo,omitzero"` } type oneBotSendPrivateMsgParams struct { @@ -236,7 +237,7 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error Echo: echo, } - data, err := json.Marshal(req) + data, err := jsonv2.Marshal(req) if err != nil { return fmt.Errorf("failed to marshal OneBot request: %w", err) } @@ -326,7 +327,7 @@ func (c *OneBotChannel) listen() { }) 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{}{ "error": err.Error(), "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 { return 0, nil } var n int64 - if err := json.Unmarshal(raw, &n); err == nil { + if err := jsonv2.Unmarshal(raw, &n); err == nil { return n, nil } 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 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 { return "" } var s string - if err := json.Unmarshal(raw, &s); err == nil { + if err := jsonv2.Unmarshal(raw, &s); err == nil { return s } @@ -388,13 +389,13 @@ type parseMessageResult struct { IsBotMentioned bool } -func parseMessageContentEx(raw json.RawMessage, selfID int64) parseMessageResult { +func parseMessageContentEx(raw jsontext.Value, selfID int64) parseMessageResult { if len(raw) == 0 { return parseMessageResult{} } var s string - if err := json.Unmarshal(raw, &s); err == nil { + if err := jsonv2.Unmarshal(raw, &s); err == nil { mentioned := false if selfID > 0 { 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{} - if err := json.Unmarshal(raw, &segments); err == nil { + if err := jsonv2.Unmarshal(raw, &segments); err == nil { var text string mentioned := false selfIDStr := strconv.FormatInt(selfID, 10) @@ -497,7 +498,7 @@ func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent var sender oneBotSender 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{}{ "error": err.Error(), "sender": string(raw.Sender), diff --git a/pkg/channels/whatsapp.go b/pkg/channels/whatsapp.go index c95e59578..e30861117 100644 --- a/pkg/channels/whatsapp.go +++ b/pkg/channels/whatsapp.go @@ -2,12 +2,12 @@ package channels import ( "context" - "encoding/json" "fmt" "log" "sync" "time" + jsonv2 "github.com/go-json-experiment/json" "github.com/gorilla/websocket" "github.com/sipeed/picoclaw/pkg/bus" @@ -92,7 +92,7 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err "content": msg.Content, } - data, err := json.Marshal(payload) + data, err := jsonv2.Marshal(payload) if err != nil { 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{} - 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) continue } diff --git a/pkg/config/config.go b/pkg/config/config.go index 659dffb6e..386e33c17 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,13 +1,14 @@ package config import ( - "encoding/json" "fmt" "os" "path/filepath" "sync" "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, @@ -17,14 +18,14 @@ type FlexibleStringSlice []string func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { // Try []string first var ss []string - if err := json.Unmarshal(data, &ss); err == nil { + if err := jsonv2.Unmarshal(data, &ss); err == nil { *f = ss return nil } // Try []interface{} to handle mixed types var raw []interface{} - if err := json.Unmarshal(data, &raw); err != nil { + if err := jsonv2.Unmarshal(data, &raw); err != nil { return err } @@ -251,10 +252,10 @@ type ProvidersConfig struct { type ProviderConfig struct { APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"` APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"` - Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"` - AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"` - Timeout int `json:"timeout,omitempty" 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` + Proxy string `json:"proxy,omitzero" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"` + AuthMethod string `json:"auth_method,omitzero" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"` + Timeout int `json:"timeout,omitzero" env:"PICOCLAW_PROVIDERS_{{.Name}}_TIMEOUT"` // seconds, 0 = default (120s) + ConnectMode string `json:"connect_mode,omitzero" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc` } type OpenAIProviderConfig struct { @@ -450,7 +451,7 @@ func LoadConfig(path string) (*Config, error) { return nil, err } - if err := json.Unmarshal(data, cfg); err != nil { + if err := jsonv2.Unmarshal(data, cfg); err != nil { return nil, err } @@ -522,7 +523,7 @@ func SaveConfig(path string, cfg *Config) error { cfg.mu.RLock() defer cfg.mu.RUnlock() - data, err := json.MarshalIndent(cfg, "", " ") + data, err := jsonv2.Marshal(cfg, jsontext.WithIndent(" ")) if err != nil { return err } diff --git a/pkg/cron/service.go b/pkg/cron/service.go index 0e425de2a..2026bea14 100644 --- a/pkg/cron/service.go +++ b/pkg/cron/service.go @@ -4,7 +4,6 @@ import ( "context" "crypto/rand" "encoding/hex" - "encoding/json" "fmt" "log" "os" @@ -13,31 +12,34 @@ import ( "time" "github.com/adhocore/gronx" + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" + "github.com/sipeed/picoclaw/pkg/memory" ) type CronSchedule struct { Kind string `json:"kind"` - AtMS *int64 `json:"atMs,omitempty"` - EveryMS *int64 `json:"everyMs,omitempty"` - Expr string `json:"expr,omitempty"` - TZ string `json:"tz,omitempty"` + AtMS *int64 `json:"atMs,omitzero"` + EveryMS *int64 `json:"everyMs,omitzero"` + Expr string `json:"expr,omitzero"` + TZ string `json:"tz,omitzero"` } type CronPayload struct { Kind string `json:"kind"` Message string `json:"message"` - Command string `json:"command,omitempty"` + Command string `json:"command,omitzero"` Deliver bool `json:"deliver"` - Channel string `json:"channel,omitempty"` - To string `json:"to,omitempty"` + Channel string `json:"channel,omitzero"` + To string `json:"to,omitzero"` } type CronJobState struct { - NextRunAtMS *int64 `json:"nextRunAtMs,omitempty"` - LastRunAtMS *int64 `json:"lastRunAtMs,omitempty"` - LastStatus string `json:"lastStatus,omitempty"` - LastError string `json:"lastError,omitempty"` + NextRunAtMS *int64 `json:"nextRunAtMs,omitzero"` + LastRunAtMS *int64 `json:"lastRunAtMs,omitzero"` + LastStatus string `json:"lastStatus,omitzero"` + LastError string `json:"lastError,omitzero"` } type CronJob struct { @@ -349,7 +351,7 @@ func (cs *CronService) loadStore() error { return err } - return json.Unmarshal(data, cs.store) + return jsonv2.Unmarshal(data, cs.store) } func (cs *CronService) loadStoreFromDelegate() error { @@ -360,7 +362,7 @@ func (cs *CronService) loadStoreFromDelegate() error { if err != nil || val == "" { return nil } - return json.Unmarshal([]byte(val), cs.store) + return jsonv2.Unmarshal([]byte(val), cs.store) } func (cs *CronService) saveStoreUnsafe() error { @@ -373,7 +375,7 @@ func (cs *CronService) saveStoreUnsafe() error { return err } - data, err := json.MarshalIndent(cs.store, "", " ") + data, err := jsonv2.Marshal(cs.store, jsontext.WithIndent(" ")) if err != nil { return err } @@ -382,7 +384,7 @@ func (cs *CronService) saveStoreUnsafe() error { } func (cs *CronService) saveStoreToDelegate() error { - data, err := json.Marshal(cs.store) + data, err := jsonv2.Marshal(cs.store) if err != nil { return err } diff --git a/pkg/fantasy/adapter.go b/pkg/fantasy/adapter.go index a270ef349..ca56f5761 100644 --- a/pkg/fantasy/adapter.go +++ b/pkg/fantasy/adapter.go @@ -7,10 +7,11 @@ package fantasy import ( "context" - "encoding/json" "fmt" "charm.land/fantasy" + jsonv2 "github.com/go-json-experiment/json" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/logger" 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{} - 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 args, nil diff --git a/pkg/fantasy/claude_cli.go b/pkg/fantasy/claude_cli.go index c23b6fec7..9f71ebbfa 100644 --- a/pkg/fantasy/claude_cli.go +++ b/pkg/fantasy/claude_cli.go @@ -8,13 +8,13 @@ package fantasy import ( "bytes" "context" - "encoding/json" "fmt" "iter" "os/exec" "strings" "charm.land/fantasy" + jsonv2 "github.com/go-json-experiment/json" ) // claudeCliProvider implements fantasy.Provider using the claude CLI subprocess. @@ -147,7 +147,7 @@ func extractTextFromCLIOutput(output []byte) string { Content string `json:"content"` Text string `json:"text"` } - if err := json.Unmarshal(output, &results); err == nil { + if err := jsonv2.Unmarshal(output, &results); err == nil { var texts []string for _, r := range results { switch { @@ -167,7 +167,7 @@ func extractTextFromCLIOutput(output []byte) string { Result string `json:"result"` Text string `json:"text"` } - if err := json.Unmarshal(output, &single); err == nil { + if err := jsonv2.Unmarshal(output, &single); err == nil { if single.Result != "" { return single.Result } diff --git a/pkg/fantasy/convert.go b/pkg/fantasy/convert.go index 2b3433d61..02c09d3c0 100644 --- a/pkg/fantasy/convert.go +++ b/pkg/fantasy/convert.go @@ -6,9 +6,9 @@ package fantasy import ( - "encoding/json" - "charm.land/fantasy" + jsonv2 "github.com/go-json-experiment/json" + "github.com/sipeed/picoclaw/pkg/messages" ) @@ -55,7 +55,7 @@ func MessageToFantasy(msg messages.Message) fantasy.Message { input = tc.Function.Arguments } else if tc.Arguments != nil { // Fallback: serialize the map to JSON. - data, _ := json.Marshal(tc.Arguments) + data, _ := jsonv2.Marshal(tc.Arguments) input = string(data) } diff --git a/pkg/health/server.go b/pkg/health/server.go index 77b36034d..5f8dc4703 100644 --- a/pkg/health/server.go +++ b/pkg/health/server.go @@ -2,11 +2,12 @@ package health import ( "context" - "encoding/json" "fmt" "net/http" "sync" "time" + + jsonv2 "github.com/go-json-experiment/json" ) type Server struct { @@ -20,14 +21,14 @@ type Server struct { type Check struct { Name string `json:"name"` Status string `json:"status"` - Message string `json:"message,omitempty"` + Message string `json:"message,omitzero"` Timestamp time.Time `json:"timestamp"` } type StatusResponse struct { Status string `json:"status"` 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 { @@ -113,7 +114,7 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { Uptime: uptime.String(), } - json.NewEncoder(w).Encode(resp) + jsonv2.MarshalWrite(w, resp) } 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 { w.WriteHeader(http.StatusServiceUnavailable) - json.NewEncoder(w).Encode(StatusResponse{ + jsonv2.MarshalWrite(w, StatusResponse{ Status: "not ready", Checks: checks, }) @@ -139,7 +140,7 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) { for _, check := range checks { if check.Status == "fail" { w.WriteHeader(http.StatusServiceUnavailable) - json.NewEncoder(w).Encode(StatusResponse{ + jsonv2.MarshalWrite(w, StatusResponse{ Status: "not ready", Checks: checks, }) @@ -149,7 +150,7 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) uptime := time.Since(s.startTime) - json.NewEncoder(w).Encode(StatusResponse{ + jsonv2.MarshalWrite(w, StatusResponse{ Status: "ready", Uptime: uptime.String(), Checks: checks, diff --git a/pkg/ids/ids_test.go b/pkg/ids/ids_test.go index 31ed6a415..40c31f1ab 100644 --- a/pkg/ids/ids_test.go +++ b/pkg/ids/ids_test.go @@ -1,8 +1,9 @@ package ids import ( - "encoding/json" "testing" + + jsonv2 "github.com/go-json-experiment/json" ) func TestNew_IsV7(t *testing.T) { @@ -159,14 +160,14 @@ func TestScan_InvalidType(t *testing.T) { func TestJSON_RoundTrip(t *testing.T) { u := New() - b, err := json.Marshal(u) + b, err := jsonv2.Marshal(u) if err != nil { t.Fatalf("Marshal: %v", err) } // Should be a quoted 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) } if s != u.String() { @@ -174,7 +175,7 @@ func TestJSON_RoundTrip(t *testing.T) { } var parsed UUID - if err := json.Unmarshal(b, &parsed); err != nil { + if err := jsonv2.Unmarshal(b, &parsed); err != nil { t.Fatalf("Unmarshal: %v", err) } if parsed != u { @@ -184,7 +185,7 @@ func TestJSON_RoundTrip(t *testing.T) { func TestJSON_ZeroUUID(t *testing.T) { var zero UUID - b, err := json.Marshal(zero) + b, err := jsonv2.Marshal(zero) if err != nil { t.Fatalf("Marshal zero: %v", err) } @@ -201,13 +202,13 @@ func TestJSON_InStruct(t *testing.T) { } r := record{ID: New(), Name: "test"} - b, err := json.Marshal(r) + b, err := jsonv2.Marshal(r) if err != nil { t.Fatalf("Marshal struct: %v", err) } 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) } if decoded.ID != r.ID { diff --git a/pkg/ids/type.go b/pkg/ids/type.go index 1c3fca822..2cd453a11 100644 --- a/pkg/ids/type.go +++ b/pkg/ids/type.go @@ -2,9 +2,10 @@ package ids import ( "database/sql/driver" - "encoding/json" "errors" "fmt" + + jsonv2 "github.com/go-json-experiment/json" ) // 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. func (u UUID) MarshalJSON() ([]byte, error) { - return json.Marshal(u.String()) + return jsonv2.Marshal(u.String()) } // UnmarshalJSON decodes UUID from JSON string. func (u *UUID) UnmarshalJSON(b []byte) error { var s string - if err := json.Unmarshal(b, &s); err != nil { + if err := jsonv2.Unmarshal(b, &s); err != nil { return err } parsed, err := Parse(s) diff --git a/pkg/itr/commands.go b/pkg/itr/commands.go index 9bb37fa33..6cb16792e 100644 --- a/pkg/itr/commands.go +++ b/pkg/itr/commands.go @@ -3,20 +3,15 @@ // recursive decomposition operations — are serialized as ToolRequest / // ToolResponse pairs. // -// The canonical schema lives in commands.fbs. This file provides the Go -// types and encoding helpers that replace the raw flatc-generated code while -// keeping the same wire format semantics. The encoding uses encoding/json -// 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. +// The canonical schema lives in commands.fbs. Wire encoding uses FlatBuffers +// for all internal transport (socket, daemon, WASM). JSON encoding is +// available via MarshalJSON / UnmarshalRequestJSON for the LLM boundary only. package itr import ( - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" "time" ) @@ -113,6 +108,10 @@ type CodeExec struct { // command payload and declares dependencies on other nodes by ID. // Args may contain "#nodeN" references that are resolved to the output of // 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 { ID string `json:"id"` Type CommandType `json:"type"` @@ -120,6 +119,92 @@ type DAGNode struct { 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 // Directed Acyclic Graph. The executor dispatches nodes in topological order, // 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) { - 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) { - 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) { - var raw struct { - ID string `json:"id"` - Type CommandType `json:"type"` - Payload json.RawMessage `json:"payload"` - Timestamp int64 `json:"timestamp"` - Depth uint8 `json:"depth"` - SessionKey string `json:"session_key"` - ToolCallID string `json:"tool_call_id"` + return UnmarshalRequestFB(data) +} + +// UnmarshalResponse decodes a ToolResponse from FlatBuffers bytes. +func UnmarshalResponse(data []byte) (ToolResponse, error) { + return UnmarshalResponseFB(data) +} + +// ── 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 } @@ -305,47 +437,51 @@ func UnmarshalRequest(data []byte) (ToolRequest, error) { ToolCallID: raw.ToolCallID, } + unmarshalPayload := func(dst any) error { + return jsonv2.Unmarshal(raw.Payload, dst, opts) + } + var err error switch raw.Type { case CmdPeek: var p Peek - err = json.Unmarshal(raw.Payload, &p) + err = unmarshalPayload(&p) req.Payload = p case CmdGrep: var p Grep - err = json.Unmarshal(raw.Payload, &p) + err = unmarshalPayload(&p) req.Payload = p case CmdPartition: var p Partition - err = json.Unmarshal(raw.Payload, &p) + err = unmarshalPayload(&p) req.Payload = p case CmdRecurse: var p Recurse - err = json.Unmarshal(raw.Payload, &p) + err = unmarshalPayload(&p) req.Payload = p case CmdToolExec: var p ToolExec - err = json.Unmarshal(raw.Payload, &p) + err = unmarshalPayload(&p) req.Payload = p case CmdExecWasm: var p ExecWasm - err = json.Unmarshal(raw.Payload, &p) + err = unmarshalPayload(&p) req.Payload = p case CmdFinal: var p Final - err = json.Unmarshal(raw.Payload, &p) + err = unmarshalPayload(&p) req.Payload = p case CmdToolSearch: var p ToolSearch - err = json.Unmarshal(raw.Payload, &p) + err = unmarshalPayload(&p) req.Payload = p case CmdCodeExec: var p CodeExec - err = json.Unmarshal(raw.Payload, &p) + err = unmarshalPayload(&p) req.Payload = p case CmdDAGPlan: var p DAGPlan - err = json.Unmarshal(raw.Payload, &p) + err = unmarshalPayload(&p) req.Payload = p default: return ToolRequest{}, fmt.Errorf("unknown command type: %q", raw.Type) @@ -354,8 +490,8 @@ func UnmarshalRequest(data []byte) (ToolRequest, error) { return req, err } -// UnmarshalResponse decodes a ToolResponse from JSON bytes. -func UnmarshalResponse(data []byte) (ToolResponse, error) { +// UnmarshalResponseJSON decodes a ToolResponse from JSON bytes. +func UnmarshalResponseJSON(data []byte) (ToolResponse, error) { var r ToolResponse - return r, json.Unmarshal(data, &r) + return r, jsonv2.Unmarshal(data, &r) } diff --git a/pkg/itr/dag/executor.go b/pkg/itr/dag/executor.go index 5e69ec76a..f2726bf70 100644 --- a/pkg/itr/dag/executor.go +++ b/pkg/itr/dag/executor.go @@ -13,7 +13,6 @@ package dag import ( "context" - "encoding/json" "fmt" "runtime" "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. type Executor struct { - bus *securebus.Bus - joiner JoinerFunc - rlmExpand RLMExpandFunc - rlmThresholdBytes int - maxParallel int + bus *securebus.Bus + joiner JoinerFunc + rlmExpand RLMExpandFunc + rlmThresholdBytes int + maxParallel int } // 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 { case <-depState.done: if _, depErr := depState.getResult(); depErr != nil { - ns.setResult("", fmt.Errorf("dependency %s failed: %w", dep, depErr)) - errOnce.Do(func() { waveErr = depErr }) + wrapped := fmt.Errorf("dependency %s failed: %w", dep, depErr) + ns.setResult("", wrapped) + errOnce.Do(func() { waveErr = wrapped }) return } 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 // dependency references in its payload. 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) } // nodeToRequest converts a DAGNode into a ToolRequest, resolving #nodeN // 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 { case itr.CmdToolExec: te, ok := node.Payload.(itr.ToolExec) if !ok { - if m, ok := node.Payload.(map[string]interface{}); ok { - b, _ := json.Marshal(m) - _ = json.Unmarshal(b, &te) - } + return itr.ToolRequest{}, fmt.Errorf("node %s: expected ToolExec payload, got %T", node.ID, node.Payload) } 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: ts, ok := node.Payload.(itr.ToolSearch) if !ok { - if m, ok := node.Payload.(map[string]interface{}); ok { - b, _ := json.Marshal(m) - _ = json.Unmarshal(b, &ts) - } + return itr.ToolRequest{}, fmt.Errorf("node %s: expected ToolSearch payload, got %T", node.ID, node.Payload) } - return itr.NewToolSearchRequest(node.ID, sessionKey, ts.Query, ts.MaxResults) + return itr.NewToolSearchRequest(node.ID, sessionKey, ts.Query, ts.MaxResults), nil default: return itr.ToolRequest{ @@ -250,7 +247,7 @@ func nodeToRequest(sessionKey string, node *itr.DAGNode, states map[string]*node Type: node.Type, Payload: node.Payload, SessionKey: sessionKey, - } + }, nil } } diff --git a/pkg/itr/dag/executor_test.go b/pkg/itr/dag/executor_test.go index 96dd3d101..efb6fdc0a 100644 --- a/pkg/itr/dag/executor_test.go +++ b/pkg/itr/dag/executor_test.go @@ -2,8 +2,8 @@ package dag_test import ( "context" - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" "testing" "github.com/sipeed/picoclaw/pkg/itr" @@ -123,7 +123,11 @@ func TestExecutor_WithJoiner(t *testing.T) { defer bus.Close() 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) @@ -173,7 +177,8 @@ func TestResolver_NodeRefSubstitution(t *testing.T) { result, err := executor.Execute(context.Background(), "test-sess", plan) 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) { @@ -225,7 +230,7 @@ func TestPlanner_ValidatePlan(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var plan itr.DAGPlan - err := json.Unmarshal([]byte(tt.plan), &plan) + err := jsonv2.Unmarshal([]byte(tt.plan), &plan) require.NoError(t, err) // Use planner with a mock that returns the pre-built plan JSON diff --git a/pkg/itr/dag/planner.go b/pkg/itr/dag/planner.go index eebee59c3..1b82a56b5 100644 --- a/pkg/itr/dag/planner.go +++ b/pkg/itr/dag/planner.go @@ -2,8 +2,8 @@ package dag import ( "context" - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" "github.com/sipeed/picoclaw/pkg/itr" "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 // with dependency edges in a single inference pass (LLMCompiler pattern). type Planner struct { - callModel PlannerFunc - registry *tools.ToolRegistry - maxParallel uint8 - tokenBudget uint32 + callModel PlannerFunc + registry *tools.ToolRegistry + maxParallel uint8 + tokenBudget uint32 } // PlannerConfig configures the DAG planner. @@ -115,32 +115,10 @@ func parsePlanResponse(response string) (*itr.DAGPlan, error) { response = extractJSON(response) 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)) } - // 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 } diff --git a/pkg/itr/dag/resolver.go b/pkg/itr/dag/resolver.go index 854b3dcbb..51182a908 100644 --- a/pkg/itr/dag/resolver.go +++ b/pkg/itr/dag/resolver.go @@ -1,8 +1,8 @@ package dag import ( - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" "regexp" "strings" "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. func escapeForJSON(s string) string { - b, err := json.Marshal(s) - if err != nil { + b, err := jsonv2.Marshal(s) + if err != nil || len(b) < 2 { return s } - // Strip surrounding quotes since we're replacing within an existing string. return string(b[1 : len(b)-1]) } diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 22f66829f..64b53198c 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -1,7 +1,6 @@ package logger import ( - "encoding/json" "fmt" "log" "os" @@ -9,6 +8,8 @@ import ( "strings" "sync" "time" + + jsonv2 "github.com/go-json-experiment/json" ) type LogLevel int @@ -43,10 +44,10 @@ type Logger struct { type LogEntry struct { Level string `json:"level"` Timestamp string `json:"timestamp"` - Component string `json:"component,omitempty"` + Component string `json:"component,omitzero"` Message string `json:"message"` - Fields map[string]interface{} `json:"fields,omitempty"` - Caller string `json:"caller,omitempty"` + Fields map[string]interface{} `json:"fields,omitzero"` + Caller string `json:"caller,omitzero"` } func init() { @@ -117,7 +118,7 @@ func logMessage(level LogLevel, component string, message string, fields map[str } if logger.file != nil { - jsonData, err := json.Marshal(entry) + jsonData, err := jsonv2.Marshal(entry) if err == nil { logger.file.WriteString(string(jsonData) + "\n") } diff --git a/pkg/memory/dag/budget.go b/pkg/memory/dag/budget.go index 3379acb04..322d19655 100644 --- a/pkg/memory/dag/budget.go +++ b/pkg/memory/dag/budget.go @@ -3,12 +3,12 @@ package dag // BudgetConfig defines the percentage allocation for each context section. // All percentages should sum to 100. type BudgetConfig struct { - SystemPromptPct int // % for system prompt (identity, rules, skills) - ObservationsPct int // % for observation block - KnowledgePct int // % for knowledge block (Focus completions) - DAGSummariesPct int // % for DAG compressed history - RawTailPct int // % for raw recent messages (uncompressed tail) - ToolResultsPct int // % for tool call results + SystemPromptPct int // % for system prompt (identity, rules, skills) + ObservationsPct int // % for observation block + KnowledgePct int // % for knowledge block (Focus completions) + DAGSummariesPct int // % for DAG compressed history + RawTailPct int // % for raw recent messages (uncompressed tail) + ToolResultsPct int // % for tool call results } // DefaultBudgetConfig returns a balanced allocation. diff --git a/pkg/memory/dag/compress.go b/pkg/memory/dag/compress.go index 5bb75be1b..fc2baf31b 100644 --- a/pkg/memory/dag/compress.go +++ b/pkg/memory/dag/compress.go @@ -14,10 +14,10 @@ type Message struct { // CompressorConfig controls the deterministic compression behavior. type CompressorConfig struct { - ChunkSize int // Messages per chunk node (default 8) - SectionSize int // Chunks per section node (default 4) - MaxSentences int // Max sentences to extract per message (default 2) - TargetRatio float64 // Target compression ratio (default 0.25 = 4:1) + ChunkSize int // Messages per chunk node (default 8) + SectionSize int // Chunks per section node (default 4) + MaxSentences int // Max sentences to extract per message (default 2) + TargetRatio float64 // Target compression ratio (default 0.25 = 4:1) } // DefaultCompressorConfig returns sensible defaults. diff --git a/pkg/memory/dag/dag_test.go b/pkg/memory/dag/dag_test.go index 0e9afd59f..da12ce11e 100644 --- a/pkg/memory/dag/dag_test.go +++ b/pkg/memory/dag/dag_test.go @@ -220,9 +220,9 @@ func TestSelectDAGLevel(t *testing.T) { func TestTailMessageCount(t *testing.T) { assert.Equal(t, 4, TailMessageCount(100)) // Minimum - assert.Equal(t, 20, TailMessageCount(1000)) // 1000/50 - assert.Equal(t, 4, TailMessageCount(0)) // Zero budget - assert.Equal(t, 4, TailMessageCount(-1)) // Negative + assert.Equal(t, 20, TailMessageCount(1000)) // 1000/50 + assert.Equal(t, 4, TailMessageCount(0)) // Zero budget + assert.Equal(t, 4, TailMessageCount(-1)) // Negative } func TestRenderDAGForBudget(t *testing.T) { diff --git a/pkg/memory/delegate/sqlite_integration_test.go b/pkg/memory/delegate/sqlite_integration_test.go index 98c2b59ef..1917bedcd 100644 --- a/pkg/memory/delegate/sqlite_integration_test.go +++ b/pkg/memory/delegate/sqlite_integration_test.go @@ -2,7 +2,7 @@ package delegate import ( "context" - "encoding/json" + jsonv2 "github.com/go-json-experiment/json" "testing" "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, d.UpsertKV(ctx, agentID, kvKey, string(data))) @@ -48,7 +48,7 @@ func TestCronKVBackend_Roundtrip(t *testing.T) { require.NotEmpty(t, raw) 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.Len(t, loaded.Jobs, 2) diff --git a/pkg/memory/migrate_sessions.go b/pkg/memory/migrate_sessions.go index b3f8ef045..3664863eb 100644 --- a/pkg/memory/migrate_sessions.go +++ b/pkg/memory/migrate_sessions.go @@ -5,8 +5,8 @@ package memory import ( "context" - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" "os" "path/filepath" "strings" @@ -23,7 +23,7 @@ const migrationMarkerFile = ".sessions_migrated" type SessionFile struct { Key string `json:"key"` Messages []SessionMsg `json:"messages"` - Summary string `json:"summary,omitempty"` + Summary string `json:"summary,omitzero"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` } @@ -80,7 +80,7 @@ func MigrateFileSessions(ctx context.Context, del MemoryDelegate, agentID, sessi } 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", map[string]interface{}{"path": sessPath, "error": err.Error()}) result.Errors++ diff --git a/pkg/memory/migrate_sessions_test.go b/pkg/memory/migrate_sessions_test.go index 7ba54cf53..93e7fd86a 100644 --- a/pkg/memory/migrate_sessions_test.go +++ b/pkg/memory/migrate_sessions_test.go @@ -2,7 +2,7 @@ package memory import ( "context" - "encoding/json" + jsonv2 "github.com/go-json-experiment/json" "os" "path/filepath" "testing" @@ -103,7 +103,7 @@ func (m *mockDelegate) CountAuditEntries(_ context.Context, _ string) (int, erro func writeSessionFile(t *testing.T, dir, name string, sess SessionFile) { t.Helper() - data, err := json.Marshal(sess) + data, err := jsonv2.Marshal(sess) if err != nil { t.Fatalf("marshal session: %v", err) } diff --git a/pkg/memory/migrate_state.go b/pkg/memory/migrate_state.go index 4882d4fe3..906eb0a85 100644 --- a/pkg/memory/migrate_state.go +++ b/pkg/memory/migrate_state.go @@ -2,7 +2,7 @@ package memory import ( "context" - "encoding/json" + jsonv2 "github.com/go-json-experiment/json" "log" "os" "path/filepath" @@ -10,8 +10,8 @@ import ( ) type legacyState struct { - LastChannel string `json:"last_channel,omitempty"` - LastChatID string `json:"last_chat_id,omitempty"` + LastChannel string `json:"last_channel,omitzero"` + LastChatID string `json:"last_chat_id,omitzero"` Timestamp time.Time `json:"timestamp"` } @@ -39,7 +39,7 @@ func MigrateState(ctx context.Context, workspace string, delegate MemoryDelegate } 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) return nil } diff --git a/pkg/memory/observation/manager.go b/pkg/memory/observation/manager.go index 8a2a469f7..bc47c7e52 100644 --- a/pkg/memory/observation/manager.go +++ b/pkg/memory/observation/manager.go @@ -15,8 +15,8 @@ type Manager struct { observer *Observer reflector *Reflector - mu sync.Mutex - running map[string]bool // sessionKey -> running flag to prevent concurrent runs + mu sync.Mutex + running map[string]bool // sessionKey -> running flag to prevent concurrent runs } // ManagerConfig bundles configuration for the observation system. diff --git a/pkg/memory/observation/observation.go b/pkg/memory/observation/observation.go index 86e9ad4f3..52464a6ee 100644 --- a/pkg/memory/observation/observation.go +++ b/pkg/memory/observation/observation.go @@ -14,8 +14,8 @@ package observation import ( - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" "strings" "time" ) @@ -25,8 +25,8 @@ type Priority string const ( PriorityCritical Priority = "critical" // 🔴 - PriorityNotable Priority = "notable" // 🟡 - PriorityInformational Priority = "informational" // 🔵 + PriorityNotable Priority = "notable" // 🟡 + PriorityInformational Priority = "informational" // 🔵 ) func (p Priority) Emoji() string { @@ -97,7 +97,7 @@ func FormatBlock(observations []Observation) string { // MarshalObservations serializes observations to JSON for KV storage. func MarshalObservations(obs []Observation) (string, error) { - data, err := json.Marshal(obs) + data, err := jsonv2.Marshal(obs) if err != nil { return "", fmt.Errorf("marshal observations: %w", err) } @@ -110,7 +110,7 @@ func UnmarshalObservations(data string) ([]Observation, error) { return nil, nil } 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 obs, nil diff --git a/pkg/memory/observation/observation_test.go b/pkg/memory/observation/observation_test.go index 370b2a3e0..4716457dd 100644 --- a/pkg/memory/observation/observation_test.go +++ b/pkg/memory/observation/observation_test.go @@ -238,8 +238,8 @@ func TestParseKeptIndices(t *testing.T) { {"normal", "KEEP 0\nDROP 1\nKEEP 2", 2}, {"all keep", "KEEP 0\nKEEP 1\nKEEP 2", 3}, {"all drop", "DROP 0\nDROP 1\nDROP 2", 1}, // Fallback keeps critical - {"invalid output", "blah blah", 1}, // Fallback keeps critical - {"out of range", "KEEP 99", 1}, // Fallback keeps critical + {"invalid output", "blah blah", 1}, // Fallback keeps critical + {"out of range", "KEEP 99", 1}, // Fallback keeps critical } for _, tc := range tests { diff --git a/pkg/memory/store/embed_factory_test.go b/pkg/memory/store/embed_factory_test.go index 91bff0178..0f75e6559 100644 --- a/pkg/memory/store/embed_factory_test.go +++ b/pkg/memory/store/embed_factory_test.go @@ -1,7 +1,7 @@ package store import ( - "encoding/json" + jsonv2 "github.com/go-json-experiment/json" "net/http" "net/http/httptest" "testing" @@ -38,11 +38,12 @@ func TestNewEmbedderFromConfig_OpenAI_NoKey(t *testing.T) { func TestNewEmbedderFromConfig_OpenAI_FallbackKey(t *testing.T) { 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{}{ {"index": 0, "embedding": []float32{0.1, 0.2, 0.3}}, }, }) + w.Write(respData) })) defer srv.Close() @@ -70,9 +71,10 @@ func TestNewEmbedderFromConfig_OpenAI_FallbackKey(t *testing.T) { func TestNewEmbedderFromConfig_Ollama(t *testing.T) { 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}}, }) + w.Write(respData) })) defer srv.Close() @@ -97,9 +99,10 @@ func TestNewEmbedderFromConfig_Ollama(t *testing.T) { func TestNewEmbedderFromConfig_Ollama_FallbackBase(t *testing.T) { 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}}, }) + w.Write(respData) })) defer srv.Close() @@ -119,9 +122,10 @@ func TestNewEmbedderFromConfig_Ollama_FallbackBase(t *testing.T) { func TestNewEmbedderFromConfig_CaseInsensitive(t *testing.T) { 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}}, }) + w.Write(respData) })) defer srv.Close() diff --git a/pkg/memory/store/embed_ollama.go b/pkg/memory/store/embed_ollama.go index e67e5bd2a..02d67c5e6 100644 --- a/pkg/memory/store/embed_ollama.go +++ b/pkg/memory/store/embed_ollama.go @@ -3,8 +3,8 @@ package store import ( "bytes" "context" - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" "io" "net/http" "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) { - body, err := json.Marshal(ollamaEmbedRequest{ + body, err := jsonv2.Marshal(ollamaEmbedRequest{ Model: o.model, Input: text, }) @@ -119,7 +119,7 @@ func (o *OllamaEmbedder) embedSingle(ctx context.Context, text string) (memory.E } 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) } diff --git a/pkg/memory/store/embed_ollama_test.go b/pkg/memory/store/embed_ollama_test.go index ff9f8267e..695f30f5e 100644 --- a/pkg/memory/store/embed_ollama_test.go +++ b/pkg/memory/store/embed_ollama_test.go @@ -2,7 +2,7 @@ package store import ( "context" - "encoding/json" + jsonv2 "github.com/go-json-experiment/json" "net/http" "net/http/httptest" "testing" @@ -18,14 +18,15 @@ func TestOllamaEmbedder_Embed(t *testing.T) { } var req ollamaEmbedRequest - json.NewDecoder(r.Body).Decode(&req) + jsonv2.UnmarshalRead(r.Body, &req) if req.Model != "test-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}}, }) + w.Write(respData) })) defer srv.Close() @@ -55,9 +56,10 @@ func TestOllamaEmbedder_EmbedBatch(t *testing.T) { callCount := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount++ - json.NewEncoder(w).Encode(ollamaEmbedResponse{ + respData, _ := jsonv2.Marshal(ollamaEmbedResponse{ Embeddings: [][]float32{{float32(callCount) * 0.1}}, }) + w.Write(respData) })) defer srv.Close() @@ -91,7 +93,8 @@ func TestOllamaEmbedder_ServerError(t *testing.T) { func TestOllamaEmbedder_EmptyResponse(t *testing.T) { 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() diff --git a/pkg/memory/store/embed_openai.go b/pkg/memory/store/embed_openai.go index 96d59e927..f1328eec0 100644 --- a/pkg/memory/store/embed_openai.go +++ b/pkg/memory/store/embed_openai.go @@ -3,8 +3,8 @@ package store import ( "bytes" "context" - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" "io" "net/http" "time" @@ -59,7 +59,7 @@ func NewOpenAIEmbedder(cfg OpenAIEmbedderConfig) *OpenAIEmbedder { type openAIEmbedRequest struct { Input interface{} `json:"input"` // string or []string Model string `json:"model"` - EncodingFormat string `json:"encoding_format,omitempty"` + EncodingFormat string `json:"encoding_format,omitzero"` } type openAIEmbedResponse struct { @@ -100,7 +100,7 @@ func (o *OpenAIEmbedder) call(ctx context.Context, texts []string) ([]memory.Emb input = texts } - body, err := json.Marshal(openAIEmbedRequest{ + body, err := jsonv2.Marshal(openAIEmbedRequest{ Input: input, Model: o.model, EncodingFormat: "float", @@ -130,7 +130,7 @@ func (o *OpenAIEmbedder) call(ctx context.Context, texts []string) ([]memory.Emb } 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) } diff --git a/pkg/memory/store/embed_openai_test.go b/pkg/memory/store/embed_openai_test.go index 0a553841b..c0736cd20 100644 --- a/pkg/memory/store/embed_openai_test.go +++ b/pkg/memory/store/embed_openai_test.go @@ -2,7 +2,7 @@ package store import ( "context" - "encoding/json" + jsonv2 "github.com/go-json-experiment/json" "net/http" "net/http/httptest" "testing" @@ -20,16 +20,17 @@ func TestOpenAIEmbedder_Embed(t *testing.T) { } var req openAIEmbedRequest - json.NewDecoder(r.Body).Decode(&req) + jsonv2.UnmarshalRead(r.Body, &req) if req.Model != "test-embed" { t.Errorf("expected model 'test-embed', got %q", req.Model) } - json.NewEncoder(w).Encode(openAIEmbedResponse{ + respData, _ := jsonv2.Marshal(openAIEmbedResponse{ Data: []openAIEmbedData{ {Index: 0, Embedding: []float32{0.5, 0.6, 0.7}}, }, }) + w.Write(respData) })) defer srv.Close() @@ -57,7 +58,7 @@ func TestOpenAIEmbedder_Embed(t *testing.T) { func TestOpenAIEmbedder_EmbedBatch(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req openAIEmbedRequest - json.NewDecoder(r.Body).Decode(&req) + jsonv2.UnmarshalRead(r.Body, &req) // Batch request should send array 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() @@ -94,16 +96,17 @@ func TestOpenAIEmbedder_EmbedBatch(t *testing.T) { func TestOpenAIEmbedder_SingleTextNotArray(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req openAIEmbedRequest - json.NewDecoder(r.Body).Decode(&req) + jsonv2.UnmarshalRead(r.Body, &req) // Single text should be sent as string, not array if _, ok := req.Input.(string); !ok { 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}}}, }) + w.Write(respData) })) defer srv.Close() @@ -140,9 +143,10 @@ func TestOpenAIEmbedder_NoAuth(t *testing.T) { if r.Header.Get("Authorization") != "" { 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}}}, }) + w.Write(respData) })) defer srv.Close() diff --git a/pkg/memory/store/memory_tool.go b/pkg/memory/store/memory_tool.go index 4484cae22..c67e6bb1e 100644 --- a/pkg/memory/store/memory_tool.go +++ b/pkg/memory/store/memory_tool.go @@ -2,8 +2,8 @@ package store import ( "context" - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" "strings" "github.com/sipeed/picoclaw/pkg/ids" @@ -25,31 +25,31 @@ const ( // MemoryToolRequest is the input to the memory tool. type MemoryToolRequest struct { Action MemoryToolAction `json:"action"` - Query string `json:"query,omitempty"` // For search - ID string `json:"id,omitempty"` // For read/update/delete - Content string `json:"content,omitempty"` // For write/update - Source string `json:"source,omitempty"` // For write - Sector string `json:"sector,omitempty"` // For write: episodic/semantic/procedural/reflective - Tags string `json:"tags,omitempty"` // For write: comma-separated - Tier string `json:"tier,omitempty"` // "recall" or "archival" — defaults to "recall" - Limit int `json:"limit,omitempty"` // For search — defaults to 5 + Query string `json:"query,omitzero"` // For search + ID string `json:"id,omitzero"` // For read/update/delete + Content string `json:"content,omitzero"` // For write/update + Source string `json:"source,omitzero"` // For write + Sector string `json:"sector,omitzero"` // For write: episodic/semantic/procedural/reflective + Tags string `json:"tags,omitzero"` // For write: comma-separated + Tier string `json:"tier,omitzero"` // "recall" or "archival" — defaults to "recall" + Limit int `json:"limit,omitzero"` // For search — defaults to 5 } // MemoryToolResponse is the output of the memory tool. type MemoryToolResponse struct { Success bool `json:"success"` - Message string `json:"message,omitempty"` - Results []MemoryToolEntry `json:"results,omitempty"` - Status *MemoryToolStatus `json:"status,omitempty"` + Message string `json:"message,omitzero"` + Results []MemoryToolEntry `json:"results,omitzero"` + Status *MemoryToolStatus `json:"status,omitzero"` } // MemoryToolEntry is a single memory entry in tool results. type MemoryToolEntry struct { ID string `json:"id"` Content string `json:"content"` - Source string `json:"source,omitempty"` - Sector string `json:"sector,omitempty"` - Score float64 `json:"score,omitempty"` + Source string `json:"source,omitzero"` + Sector string `json:"sector,omitzero"` + Score float64 `json:"score,omitzero"` } // 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. func (t *MemoryTool) Execute(ctx context.Context, input string) (string, error) { 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 } @@ -325,6 +325,6 @@ func (t *MemoryTool) errorResponse(msg string) string { } func (t *MemoryTool) jsonResponse(resp *MemoryToolResponse) string { - b, _ := json.Marshal(resp) + b, _ := jsonv2.Marshal(resp) return string(b) } diff --git a/pkg/memory/store/memory_tool_test.go b/pkg/memory/store/memory_tool_test.go index 8937a0277..34c688157 100644 --- a/pkg/memory/store/memory_tool_test.go +++ b/pkg/memory/store/memory_tool_test.go @@ -2,7 +2,7 @@ package store import ( "context" - "encoding/json" + jsonv2 "github.com/go-json-experiment/json" "testing" "github.com/stretchr/testify/assert" @@ -22,7 +22,7 @@ func executeAndParse(t *testing.T, tool *MemoryTool, input string) *MemoryToolRe require.NoError(t, err) var resp MemoryToolResponse - require.NoError(t, json.Unmarshal([]byte(raw), &resp)) + require.NoError(t, jsonv2.Unmarshal([]byte(raw), &resp)) return &resp } diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go index 57032e566..70a3b3373 100644 --- a/pkg/migrate/config.go +++ b/pkg/migrate/config.go @@ -1,13 +1,14 @@ package migrate import ( - "encoding/json" "fmt" "os" "path/filepath" "strings" "unicode" + jsonv2 "github.com/go-json-experiment/json" + "github.com/sipeed/picoclaw/pkg/config" ) @@ -51,7 +52,7 @@ func LoadOpenClawConfig(configPath string) (map[string]interface{}, error) { } 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) } diff --git a/pkg/migrate/migrate_test.go b/pkg/migrate/migrate_test.go index be2360aac..346bfed19 100644 --- a/pkg/migrate/migrate_test.go +++ b/pkg/migrate/migrate_test.go @@ -1,11 +1,12 @@ package migrate import ( - "encoding/json" "os" "path/filepath" "testing" + jsonv2 "github.com/go-json-experiment/json" + "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 { 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) 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) 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) 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) opts := Options{ diff --git a/pkg/pcerrors/cli.go b/pkg/pcerrors/cli.go index b4a68dd49..d95232479 100644 --- a/pkg/pcerrors/cli.go +++ b/pkg/pcerrors/cli.go @@ -1,12 +1,13 @@ package pcerrors import ( - "encoding/json" "fmt" "io" "os" "regexp" "strings" + + jsonv2 "github.com/go-json-experiment/json" ) // CLIHandler is the PicoClaw error lifecycle boundary for the CLI. @@ -50,7 +51,7 @@ func (h CLIHandler) Handle(err error) int { if h.Verbose { record["detail"] = err.Error() } - data, _ := json.Marshal(record) + data, _ := jsonv2.Marshal(record) _, _ = fmt.Fprintln(w, string(data)) } else { _, _ = fmt.Fprintln(w, msg) diff --git a/pkg/security/jsonextract.go b/pkg/security/jsonextract.go index 34bff40b1..ce6416155 100644 --- a/pkg/security/jsonextract.go +++ b/pkg/security/jsonextract.go @@ -2,9 +2,9 @@ package security import ( - "encoding/json" "errors" "fmt" + jsonv2 "github.com/go-json-experiment/json" "strings" ) @@ -50,12 +50,12 @@ func ExtractJSON(text string, dest interface{}, opts *ExtractJSONOptions) error return ErrNoJSON } - dec := json.NewDecoder(strings.NewReader(cleaned)) + var jsonOpts []jsonv2.Options 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 nil diff --git a/pkg/security/jsonextract_test.go b/pkg/security/jsonextract_test.go index 1b27fd2f5..b4076f525 100644 --- a/pkg/security/jsonextract_test.go +++ b/pkg/security/jsonextract_test.go @@ -107,7 +107,7 @@ func TestExtractJSON_InjectionAttempts(t *testing.T) { &ExtractJSONOptions{DisallowUnknownFields: true}, func(t *testing.T, err error) { assert.Error(t, err) - assert.Contains(t, err.Error(), "unknown field") + assert.Contains(t, err.Error(), "unknown object member name") }, }, { diff --git a/pkg/security/secretstore.go b/pkg/security/secretstore.go index c692e7035..973dab7e9 100644 --- a/pkg/security/secretstore.go +++ b/pkg/security/secretstore.go @@ -1,9 +1,10 @@ package security import ( - "encoding/json" "errors" "fmt" + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" "os" "path/filepath" "sync" @@ -132,7 +133,7 @@ func (ss *SecretStore) load() error { return err } 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) } for _, e := range entries { @@ -150,7 +151,7 @@ func (ss *SecretStore) save() error { } ss.mu.RUnlock() - data, err := json.MarshalIndent(entries, "", " ") + data, err := jsonv2.Marshal(entries, jsontext.WithIndent(" ")) if err != nil { return fmt.Errorf("marshal secret store: %w", err) } diff --git a/pkg/security/securebus/bus.go b/pkg/security/securebus/bus.go index 949dc156d..8ebf4abef 100644 --- a/pkg/security/securebus/bus.go +++ b/pkg/security/securebus/bus.go @@ -2,8 +2,10 @@ package securebus import ( "context" - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" + "log" + "sync" "time" "github.com/sipeed/picoclaw/pkg/itr" @@ -62,6 +64,7 @@ type Bus struct { executor ToolExecutor toolSearch ToolSearchFunc // nil = no tool search support done chan struct{} + closeOnce sync.Once } // New creates a Bus and starts background worker goroutines. @@ -106,10 +109,12 @@ func (b *Bus) AuditLog() *AuditLog { 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() { - b.transport.Close() - close(b.done) + b.closeOnce.Do(func() { + b.transport.Close() + close(b.done) + }) } // 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: resp := b.handleToolSearch(ctx, req) 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 default: resp := b.handleRLMCommand(ctx, req) 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 } @@ -168,7 +177,9 @@ func (b *Bus) dispatch(ctx context.Context, req itr.ToolRequest) itr.ToolRespons if !ok { event.IsError = true 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") } event.ToolName = te.ToolName @@ -184,17 +195,21 @@ func (b *Bus) dispatch(ctx context.Context, req itr.ToolRequest) itr.ToolRespons event.IsError = true event.PolicyViolation = err.Error() 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()) } // 3. Deserialise args — always produce a non-nil map for safe injection. args := make(map[string]interface{}) 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.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)) } } @@ -204,7 +219,9 @@ func (b *Bus) dispatch(ctx context.Context, req itr.ToolRequest) itr.ToolRespons if err != nil { event.IsError = true 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()) } event.SecretsAccessed = injectedSecrets @@ -232,7 +249,9 @@ func (b *Bus) dispatch(ctx context.Context, req itr.ToolRequest) itr.ToolRespons event.IsError = resp.IsError 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 } diff --git a/pkg/security/securebus/bus_test.go b/pkg/security/securebus/bus_test.go index 30069c8d8..78a726b44 100644 --- a/pkg/security/securebus/bus_test.go +++ b/pkg/security/securebus/bus_test.go @@ -2,7 +2,7 @@ package securebus_test import ( "context" - "encoding/json" + jsonv2 "github.com/go-json-experiment/json" "testing" "github.com/sipeed/picoclaw/pkg/itr" @@ -49,7 +49,7 @@ func makeArgsJSON(kv map[string]interface{}) string { if kv == nil { return "{}" } - b, _ := json.Marshal(kv) + b, _ := jsonv2.Marshal(kv) return string(b) } @@ -248,3 +248,56 @@ func TestBus_RLMFinalCommand(t *testing.T) { assert.False(t, resp.IsError) 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) +} diff --git a/pkg/session/manager.go b/pkg/session/manager.go index 65f621dfa..57ab418f2 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -2,13 +2,15 @@ package session import ( "context" - "encoding/json" "os" "path/filepath" "strings" "sync" "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/ids" "github.com/sipeed/picoclaw/pkg/logger" @@ -19,7 +21,7 @@ import ( type Session struct { Key string `json:"key"` Messages []messages.Message `json:"messages"` - Summary string `json:"summary,omitempty"` + Summary string `json:"summary,omitzero"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` } @@ -197,7 +199,7 @@ func (sm *SessionManager) loadSessionFromDisk(key string) *Session { return nil } var session Session - if err := json.Unmarshal(data, &session); err != nil { + if err := jsonv2.Unmarshal(data, &session); err != nil { return nil } return &session @@ -487,7 +489,7 @@ func snapshotSession(s *Session) Session { } func (sm *SessionManager) writeSessionToDisk(key string, session *Session) error { - data, err := json.MarshalIndent(session, "", " ") + data, err := jsonv2.Marshal(session, jsontext.WithIndent(" ")) if err != nil { return err } @@ -558,7 +560,7 @@ func (sm *SessionManager) loadSessions() error { } var session Session - if err := json.Unmarshal(data, &session); err != nil { + if err := jsonv2.Unmarshal(data, &session); err != nil { continue } diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go index a3263c525..e1235e3f0 100644 --- a/pkg/skills/installer.go +++ b/pkg/skills/installer.go @@ -2,7 +2,6 @@ package skills import ( "context" - "encoding/json" "fmt" "io" "net/http" @@ -10,6 +9,8 @@ import ( "path/filepath" "strings" "time" + + jsonv2 "github.com/go-json-experiment/json" ) type SkillInstaller struct { @@ -117,7 +118,7 @@ func (si *SkillInstaller) ListAvailableSkills(ctx context.Context) ([]AvailableS } 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) } diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go index 7eeed8bff..909810e34 100644 --- a/pkg/skills/loader.go +++ b/pkg/skills/loader.go @@ -1,7 +1,6 @@ package skills import ( - "encoding/json" "errors" "fmt" "log/slog" @@ -9,6 +8,8 @@ import ( "path/filepath" "regexp" "strings" + + jsonv2 "github.com/go-json-experiment/json" ) var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`) @@ -21,10 +22,10 @@ const ( type SkillMetadata struct { Name string `json:"name"` Description string `json:"description"` - Tags []string `json:"tags,omitempty"` - Links []string `json:"links,omitempty"` - Domain string `json:"domain,omitempty"` - IsMOC bool `json:"is_moc,omitempty"` + Tags []string `json:"tags,omitzero"` + Links []string `json:"links,omitzero"` + Domain string `json:"domain,omitzero"` + IsMOC bool `json:"is_moc,omitzero"` } type SkillInfo struct { @@ -32,8 +33,8 @@ type SkillInfo struct { Path string `json:"path"` Source string `json:"source"` Description string `json:"description"` - Tags []string `json:"tags,omitempty"` - Domain string `json:"domain,omitempty"` + Tags []string `json:"tags,omitzero"` + Domain string `json:"domain,omitzero"` } func (info SkillInfo) validate() error { @@ -124,18 +125,18 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo { continue } - info := SkillInfo{ - Name: dir.Name(), - Path: skillFile, - Source: "global", - } - metadata := sl.getSkillMetadata(skillFile) - if metadata != nil { - info.Description = metadata.Description - info.Name = metadata.Name - info.Tags = metadata.Tags - info.Domain = metadata.Domain - } + info := SkillInfo{ + Name: dir.Name(), + Path: skillFile, + Source: "global", + } + metadata := sl.getSkillMetadata(skillFile) + if metadata != nil { + info.Description = metadata.Description + info.Name = metadata.Name + info.Tags = metadata.Tags + info.Domain = metadata.Domain + } if err := info.validate(); err != nil { slog.Warn("invalid skill from global", "name", info.Name, "error", err) continue @@ -165,18 +166,18 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo { continue } - info := SkillInfo{ - Name: dir.Name(), - Path: skillFile, - Source: "builtin", - } - metadata := sl.getSkillMetadata(skillFile) - if metadata != nil { - info.Description = metadata.Description - info.Name = metadata.Name - info.Tags = metadata.Tags - info.Domain = metadata.Domain - } + info := SkillInfo{ + Name: dir.Name(), + Path: skillFile, + Source: "builtin", + } + metadata := sl.getSkillMetadata(skillFile) + if metadata != nil { + info.Description = metadata.Description + info.Name = metadata.Name + info.Tags = metadata.Tags + info.Domain = metadata.Domain + } if err := info.validate(); err != nil { slog.Warn("invalid skill from builtin", "name", info.Name, "error", err) continue @@ -281,7 +282,7 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata { // Try JSON first (for backward compatibility) 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 } diff --git a/pkg/state/state.go b/pkg/state/state.go index 061516208..669b1799e 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -2,7 +2,6 @@ package state import ( "context" - "encoding/json" "fmt" "log" "os" @@ -10,6 +9,9 @@ import ( "sync" "time" + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" + "github.com/sipeed/picoclaw/pkg/memory" ) @@ -19,10 +21,10 @@ const kvAgentID = "picoclaw" // It includes information about the last active channel/chat. type State struct { // 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 string `json:"last_chat_id,omitempty"` + LastChatID string `json:"last_chat_id,omitzero"` // Timestamp is the last time this state was updated 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 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() 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" // Marshal state to JSON - data, err := json.MarshalIndent(sm.state, "", " ") + data, err := jsonv2.Marshal(sm.state, jsontext.WithIndent(" ")) if err != nil { 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) } - 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) } diff --git a/pkg/state/state_test.go b/pkg/state/state_test.go index ce3dd7215..b7885e3da 100644 --- a/pkg/state/state_test.go +++ b/pkg/state/state_test.go @@ -1,11 +1,12 @@ package state import ( - "encoding/json" "fmt" "os" "path/filepath" "testing" + + jsonv2 "github.com/go-json-experiment/json" ) func TestAtomicSave(t *testing.T) { @@ -162,7 +163,7 @@ func TestConcurrentAccess(t *testing.T) { } 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) } } diff --git a/pkg/tools/call.go b/pkg/tools/call.go index 4d1efbea7..5c8b9d09b 100644 --- a/pkg/tools/call.go +++ b/pkg/tools/call.go @@ -2,9 +2,10 @@ package tools import ( "context" - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" + "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 { 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)) } case nil: diff --git a/pkg/tools/focus.go b/pkg/tools/focus.go index d39a3d9b5..e85515dda 100644 --- a/pkg/tools/focus.go +++ b/pkg/tools/focus.go @@ -2,11 +2,12 @@ package tools import ( "context" - "encoding/json" "fmt" "strings" "time" + jsonv2 "github.com/go-json-experiment/json" + "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/messages" "github.com/sipeed/picoclaw/pkg/session" @@ -109,7 +110,7 @@ func (t *StartFocusTool) Execute(ctx context.Context, args map[string]interface{ StartedAt: time.Now(), } - data, err := json.Marshal(state) + data, err := jsonv2.Marshal(state) if err != nil { 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 - 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)) } @@ -251,7 +252,7 @@ func (t *CompleteFocusTool) appendKnowledge(ctx context.Context, sessionKey, top kb := &KnowledgeBlock{} raw, err := t.delegate.GetKV(ctx, focusAgentID, kvKey) if err == nil && raw != "" { - _ = json.Unmarshal([]byte(raw), kb) + _ = jsonv2.Unmarshal([]byte(raw), kb) } kb.Entries = append(kb.Entries, KnowledgeEntry{ @@ -260,7 +261,7 @@ func (t *CompleteFocusTool) appendKnowledge(ctx context.Context, sessionKey, top CreatedAt: time.Now(), }) - data, err := json.Marshal(kb) + data, err := jsonv2.Marshal(kb) if err != nil { return err } @@ -280,7 +281,7 @@ func LoadKnowledgeBlock(ctx context.Context, delegate KVStore, sessionKey string } var kb KnowledgeBlock - if err := json.Unmarshal([]byte(raw), &kb); err != nil { + if err := jsonv2.Unmarshal([]byte(raw), &kb); err != nil { return "" } diff --git a/pkg/tools/focus_test.go b/pkg/tools/focus_test.go index 982bf04a4..1cd7f9f93 100644 --- a/pkg/tools/focus_test.go +++ b/pkg/tools/focus_test.go @@ -2,9 +2,10 @@ package tools import ( "context" - "encoding/json" "testing" + jsonv2 "github.com/go-json-experiment/json" + "github.com/sipeed/picoclaw/pkg/messages" "github.com/sipeed/picoclaw/pkg/session" "github.com/stretchr/testify/assert" @@ -75,7 +76,7 @@ func TestStartFocus(t *testing.T) { require.NotEmpty(t, raw) 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, 2, state.CheckpointIndex) } @@ -134,7 +135,7 @@ func TestCompleteFocus(t *testing.T) { require.NotEmpty(t, knowledgeRaw) 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) assert.Equal(t, "debug auth", kb.Entries[0].Topic) 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) 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) assert.Equal(t, "topic A", kb.Entries[0].Topic) assert.Equal(t, "topic B", kb.Entries[1].Topic) @@ -286,7 +287,7 @@ func TestLoadKnowledgeBlock(t *testing.T) { {Topic: "Test", Summary: "Test summary"}, }, } - data, _ := json.Marshal(kb) + data, _ := jsonv2.Marshal(kb) _ = delegate.UpsertKV(ctx, focusAgentID, knowledgeKVPrefix+"test-session", string(data)) block = LoadKnowledgeBlock(ctx, delegate, "test-session") diff --git a/pkg/tools/i2c.go b/pkg/tools/i2c.go index abca5ec1e..87fd179cf 100644 --- a/pkg/tools/i2c.go +++ b/pkg/tools/i2c.go @@ -2,11 +2,13 @@ package tools import ( "context" - "encoding/json" "fmt" "path/filepath" "regexp" "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. @@ -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))) } diff --git a/pkg/tools/i2c_linux.go b/pkg/tools/i2c_linux.go index 294f7ecbc..bebb2c76b 100644 --- a/pkg/tools/i2c_linux.go +++ b/pkg/tools/i2c_linux.go @@ -1,10 +1,12 @@ package tools import ( - "encoding/json" "fmt" "syscall" "unsafe" + + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" ) // I2C ioctl constants from Linux kernel headers (, ) @@ -104,7 +106,7 @@ func (t *I2CTool) scan(args map[string]interface{}) *ToolResult { type deviceEntry struct { Address string `json:"address"` - Status string `json:"status,omitempty"` + Status string `json:"status,omitzero"` } 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)) } - result, _ := json.MarshalIndent(map[string]interface{}{ + result, _ := jsonv2.Marshal(map[string]interface{}{ "bus": devPath, "devices": found, "count": len(found), - }, "", " ") + }, jsontext.WithIndent(" ")) 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]) } - result, _ := json.MarshalIndent(map[string]interface{}{ + result, _ := jsonv2.Marshal(map[string]interface{}{ "bus": devPath, "address": fmt.Sprintf("0x%02x", addr), "bytes": intBytes, "hex": hexBytes, "length": n, - }, "", " ") + }, jsontext.WithIndent(" ")) return SilentResult(string(result)) } diff --git a/pkg/tools/result.go b/pkg/tools/result.go index b13055b1c..7d5715fd3 100644 --- a/pkg/tools/result.go +++ b/pkg/tools/result.go @@ -1,6 +1,6 @@ package tools -import "encoding/json" +import jsonv2 "github.com/go-json-experiment/json" // ToolResult represents the structured return value from tool execution. // 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. func (tr *ToolResult) MarshalJSON() ([]byte, error) { type Alias ToolResult - return json.Marshal(&struct { + return jsonv2.Marshal(&struct { *Alias }{ Alias: (*Alias)(tr), diff --git a/pkg/tools/result_test.go b/pkg/tools/result_test.go index bc798cd70..a20b3463e 100644 --- a/pkg/tools/result_test.go +++ b/pkg/tools/result_test.go @@ -1,9 +1,10 @@ package tools import ( - "encoding/json" "errors" "testing" + + jsonv2 "github.com/go-json-experiment/json" ) func TestNewToolResult(t *testing.T) { @@ -125,14 +126,14 @@ func TestToolResultJSONSerialization(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Marshal to JSON - data, err := json.Marshal(tt.result) + data, err := jsonv2.Marshal(tt.result) if err != nil { t.Fatalf("Failed to marshal: %v", err) } // Unmarshal back 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) } @@ -168,13 +169,13 @@ func TestToolResultWithErrors(t *testing.T) { } // Verify Err is not serialized - data, marshalErr := json.Marshal(result) + data, marshalErr := jsonv2.Marshal(result) if marshalErr != nil { t.Fatalf("Failed to marshal: %v", marshalErr) } 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) } @@ -186,14 +187,14 @@ func TestToolResultWithErrors(t *testing.T) { func TestToolResultJSONStructure(t *testing.T) { result := UserResult("test content") - data, err := json.Marshal(result) + data, err := jsonv2.Marshal(result) if err != nil { t.Fatalf("Failed to marshal: %v", err) } // Verify JSON structure 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) } diff --git a/pkg/tools/search.go b/pkg/tools/search.go index 1233d05fc..8bb50ef57 100644 --- a/pkg/tools/search.go +++ b/pkg/tools/search.go @@ -2,10 +2,11 @@ package tools import ( "context" - "encoding/json" "fmt" "sort" "strings" + + jsonv2 "github.com/go-json-experiment/json" ) // ToolSearchTool implements tool discovery via fuzzy search over the registry. @@ -88,7 +89,7 @@ func (t *ToolSearchTool) Execute(_ context.Context, args map[string]interface{}) return &ToolResult{ForLLM: fmt.Sprintf("No tools match query: %q. Try a broader search or use tool_search with no query to list all.", query)} } - b, _ := json.Marshal(results) + b, _ := jsonv2.Marshal(results) return &ToolResult{ForLLM: string(b)} } @@ -111,7 +112,7 @@ func (t *ToolSearchTool) listAll() *ToolResult { return results[i].Name < results[j].Name }) - b, _ := json.Marshal(results) + b, _ := jsonv2.Marshal(results) return &ToolResult{ForLLM: string(b)} } diff --git a/pkg/tools/search_test.go b/pkg/tools/search_test.go index 6a69216d1..df8549a71 100644 --- a/pkg/tools/search_test.go +++ b/pkg/tools/search_test.go @@ -2,8 +2,9 @@ package tools import ( "context" - "encoding/json" "testing" + + jsonv2 "github.com/go-json-experiment/json" ) func TestToolSearchTool_Name(t *testing.T) { @@ -52,7 +53,7 @@ func TestToolSearchTool_ListAll(t *testing.T) { } var results []toolSearchResult - if err := json.Unmarshal([]byte(result.ForLLM), &results); err != nil { + if err := jsonv2.Unmarshal([]byte(result.ForLLM), &results); err != nil { t.Fatalf("failed to unmarshal: %v", err) } @@ -70,7 +71,7 @@ func TestToolSearchTool_EmptyQuery_ListsAll(t *testing.T) { result := s.Execute(context.Background(), map[string]interface{}{"query": ""}) var results []toolSearchResult - if err := json.Unmarshal([]byte(result.ForLLM), &results); err != nil { + if err := jsonv2.Unmarshal([]byte(result.ForLLM), &results); err != nil { t.Fatalf("failed to unmarshal: %v", err) } @@ -94,7 +95,7 @@ func TestToolSearchTool_ExactNameMatch(t *testing.T) { result := s.Execute(context.Background(), map[string]interface{}{"query": "read_file"}) var results []toolSearchResult - json.Unmarshal([]byte(result.ForLLM), &results) + jsonv2.Unmarshal([]byte(result.ForLLM), &results) if len(results) == 0 { t.Fatal("expected at least one result") @@ -116,7 +117,7 @@ func TestToolSearchTool_PartialMatch(t *testing.T) { result := s.Execute(context.Background(), map[string]interface{}{"query": "file"}) var results []toolSearchResult - json.Unmarshal([]byte(result.ForLLM), &results) + jsonv2.Unmarshal([]byte(result.ForLLM), &results) // Both file tools should match, web_search should not (unless "file" appears somewhere) if len(results) < 2 { @@ -133,7 +134,7 @@ func TestToolSearchTool_DescriptionMatch(t *testing.T) { result := s.Execute(context.Background(), map[string]interface{}{"query": "internet"}) var results []toolSearchResult - json.Unmarshal([]byte(result.ForLLM), &results) + jsonv2.Unmarshal([]byte(result.ForLLM), &results) if len(results) != 1 { t.Errorf("expected 1 result, got %d", len(results)) @@ -153,7 +154,7 @@ func TestToolSearchTool_MultiTermQuery(t *testing.T) { result := s.Execute(context.Background(), map[string]interface{}{"query": "web search"}) var results []toolSearchResult - json.Unmarshal([]byte(result.ForLLM), &results) + jsonv2.Unmarshal([]byte(result.ForLLM), &results) if len(results) == 0 { t.Fatal("expected at least one result") @@ -189,7 +190,7 @@ func TestToolSearchTool_ExcludesMetaTools(t *testing.T) { result := s.Execute(context.Background(), map[string]interface{}{}) var results []toolSearchResult - json.Unmarshal([]byte(result.ForLLM), &results) + jsonv2.Unmarshal([]byte(result.ForLLM), &results) for _, res := range results { if res.Name == "tool_search" || res.Name == "tool_call" { diff --git a/pkg/tools/spi.go b/pkg/tools/spi.go index 4805d6a35..60d418a40 100644 --- a/pkg/tools/spi.go +++ b/pkg/tools/spi.go @@ -2,11 +2,13 @@ package tools import ( "context" - "encoding/json" "fmt" "path/filepath" "regexp" "runtime" + + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" ) // SPITool provides SPI bus interaction for high-speed peripheral communication. @@ -113,7 +115,7 @@ func (t *SPITool) list() *ToolResult { } } - result, _ := json.MarshalIndent(devices, "", " ") + result, _ := jsonv2.Marshal(devices, jsontext.WithIndent(" ")) return SilentResult(fmt.Sprintf("Found %d SPI device(s):\n%s", len(devices), string(result))) } diff --git a/pkg/tools/spi_linux.go b/pkg/tools/spi_linux.go index 12b696007..e90af04d3 100644 --- a/pkg/tools/spi_linux.go +++ b/pkg/tools/spi_linux.go @@ -1,11 +1,13 @@ package tools import ( - "encoding/json" "fmt" "runtime" "syscall" "unsafe" + + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" ) // SPI ioctl constants from Linux kernel headers. @@ -130,12 +132,12 @@ func (t *SPITool) transfer(args map[string]interface{}) *ToolResult { intBytes[i] = int(b) } - result, _ := json.MarshalIndent(map[string]interface{}{ + result, _ := jsonv2.Marshal(map[string]interface{}{ "device": devPath, "sent": len(txBuf), "received": intBytes, "hex": hexBytes, - }, "", " ") + }, jsontext.WithIndent(" ")) return SilentResult(string(result)) } @@ -186,11 +188,11 @@ func (t *SPITool) readDevice(args map[string]interface{}) *ToolResult { intBytes[i] = int(b) } - result, _ := json.MarshalIndent(map[string]interface{}{ + result, _ := jsonv2.Marshal(map[string]interface{}{ "device": devPath, "bytes": intBytes, "hex": hexBytes, "length": len(rxBuf), - }, "", " ") + }, jsontext.WithIndent(" ")) return SilentResult(string(result)) } diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go index 0c530fbb2..86b528cbb 100644 --- a/pkg/tools/toolloop.go +++ b/pkg/tools/toolloop.go @@ -8,9 +8,10 @@ package tools import ( "context" - "encoding/json" "fmt" + jsonv2 "github.com/go-json-experiment/json" + fantasy "charm.land/fantasy" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/logger" @@ -257,7 +258,7 @@ func parseToolCallArgs(input string) (map[string]interface{}, error) { } 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 args, nil diff --git a/pkg/tools/web.go b/pkg/tools/web.go index f4c6dfc72..bbf6acb01 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -2,7 +2,6 @@ package tools import ( "context" - "encoding/json" "fmt" "io" "net/http" @@ -10,6 +9,9 @@ import ( "regexp" "strings" "time" + + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" ) const ( @@ -58,7 +60,7 @@ func (p *BraveSearchProvider) Search(ctx context.Context, query string, count in } `json:"web"` } - if err := json.Unmarshal(body, &searchResp); err != nil { + if err := jsonv2.Unmarshal(body, &searchResp); err != nil { // Log error body for debugging fmt.Printf("Brave API Error Body: %s\n", string(body)) return "", fmt.Errorf("failed to parse response: %w", err) @@ -192,7 +194,7 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou "max_tokens": 1000, } - payloadBytes, err := json.Marshal(payload) + payloadBytes, err := jsonv2.Marshal(payload) if err != nil { return "", fmt.Errorf("failed to marshal request: %w", err) } @@ -230,7 +232,7 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou } `json:"choices"` } - if err := json.Unmarshal(body, &searchResp); err != nil { + if err := jsonv2.Unmarshal(body, &searchResp); err != nil { return "", fmt.Errorf("failed to parse response: %w", err) } @@ -467,8 +469,8 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{}) if strings.Contains(contentType, "application/json") { var jsonData interface{} - if err := json.Unmarshal(body, &jsonData); err == nil { - formatted, _ := json.MarshalIndent(jsonData, "", " ") + if err := jsonv2.Unmarshal(body, &jsonData); err == nil { + formatted, _ := jsonv2.Marshal(jsonData, jsontext.WithIndent(" ")) text = string(formatted) extractor = "json" } else { @@ -498,7 +500,7 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{}) "text": text, } - resultJSON, _ := json.MarshalIndent(result, "", " ") + resultJSON, _ := jsonv2.Marshal(result, jsontext.WithIndent(" ")) return &ToolResult{ ForLLM: fmt.Sprintf("Fetched %d bytes from %s (extractor: %s, truncated: %v)", len(text), urlStr, extractor, truncated), diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go index a526ea34a..99b31ebbf 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/web_test.go @@ -2,11 +2,13 @@ package tools import ( "context" - "encoding/json" "net/http" "net/http/httptest" "strings" "testing" + + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" ) // TestWebTool_WebFetch_Success verifies successful URL fetching @@ -45,7 +47,7 @@ func TestWebTool_WebFetch_Success(t *testing.T) { // TestWebTool_WebFetch_JSON verifies JSON content handling func TestWebTool_WebFetch_JSON(t *testing.T) { testData := map[string]string{"key": "value", "number": "123"} - expectedJSON, _ := json.MarshalIndent(testData, "", " ") + expectedJSON, _ := jsonv2.Marshal(testData, jsontext.WithIndent(" ")) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -160,7 +162,7 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) { // ForUser should contain truncated content (not the full 20000 chars) resultMap := make(map[string]interface{}) - json.Unmarshal([]byte(result.ForUser), &resultMap) + jsonv2.Unmarshal([]byte(result.ForUser), &resultMap) if text, ok := resultMap["text"].(string); ok { if len(text) > 1100 { // Allow some margin t.Errorf("Expected content to be truncated to ~1000 chars, got: %d", len(text)) diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go index 9af2ea6bb..4963654c0 100644 --- a/pkg/voice/transcriber.go +++ b/pkg/voice/transcriber.go @@ -3,7 +3,6 @@ package voice import ( "bytes" "context" - "encoding/json" "fmt" "io" "mime/multipart" @@ -12,6 +11,8 @@ import ( "path/filepath" "time" + jsonv2 "github.com/go-json-experiment/json" + "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -24,8 +25,8 @@ type GroqTranscriber struct { type TranscriptionResponse struct { Text string `json:"text"` - Language string `json:"language,omitempty"` - Duration float64 `json:"duration,omitempty"` + Language string `json:"language,omitzero"` + Duration float64 `json:"duration,omitzero"` } func NewGroqTranscriber(apiKey string) *GroqTranscriber { @@ -137,7 +138,7 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) }) var result TranscriptionResponse - if err := json.Unmarshal(body, &result); err != nil { + if err := jsonv2.Unmarshal(body, &result); err != nil { logger.ErrorCF("voice", "Failed to unmarshal response", map[string]interface{}{"error": err}) return nil, fmt.Errorf("failed to unmarshal response: %w", err) }