diff --git a/backend/api/model/crossdomain/plugin/consts.go b/backend/api/model/crossdomain/plugin/consts.go index 6c9d7b43..eb90fe15 100644 --- a/backend/api/model/crossdomain/plugin/consts.go +++ b/backend/api/model/crossdomain/plugin/consts.go @@ -77,6 +77,7 @@ type InvalidResponseProcessStrategy int8 const ( InvalidResponseProcessStrategyOfReturnRaw InvalidResponseProcessStrategy = 0 // If the value of a field is invalid, the raw response value of the field is returned. InvalidResponseProcessStrategyOfReturnDefault InvalidResponseProcessStrategy = 1 // If the value of a field is invalid, the default value of the field is returned. + InvalidResponseProcessStrategyOfReturnErr InvalidResponseProcessStrategy = 2 // If the value of a field is invalid, error is returned. ) const ( diff --git a/backend/api/model/crossdomain/plugin/openai.go b/backend/api/model/crossdomain/plugin/openai.go index f4c03b03..66f4d231 100644 --- a/backend/api/model/crossdomain/plugin/openai.go +++ b/backend/api/model/crossdomain/plugin/openai.go @@ -181,6 +181,9 @@ func (op *Openapi3Operation) ToEinoSchemaParameterInfo(ctx context.Context) (map if err != nil { return nil, err } + if subParam == nil { + continue + } subParams[paramName] = subParam } diff --git a/backend/application/plugin/plugin.go b/backend/application/plugin/plugin.go index f7e9cc98..dfd3b036 100644 --- a/backend/application/plugin/plugin.go +++ b/backend/application/plugin/plugin.go @@ -1152,6 +1152,16 @@ func (p *PluginApplicationService) DebugAPI(ctx context.Context, req *pluginAPI. Resp: "{}", } + opts := []model.ExecuteToolOpt{} + switch req.Operation { + case common.DebugOperation_Debug: + opts = append(opts, model.WithInvalidRespProcessStrategy(model.InvalidResponseProcessStrategyOfReturnErr)) + case common.DebugOperation_Parse: + opts = append(opts, model.WithAutoGenRespSchema(), + model.WithInvalidRespProcessStrategy(model.InvalidResponseProcessStrategyOfReturnRaw), + ) + } + res, err := p.DomainSVC.ExecuteTool(ctx, &service.ExecuteToolRequest{ UserID: conv.Int64ToStr(*userID), PluginID: req.PluginID, @@ -1159,7 +1169,7 @@ func (p *PluginApplicationService) DebugAPI(ctx context.Context, req *pluginAPI. ExecScene: model.ExecSceneOfToolDebug, ExecDraftTool: true, ArgumentsInJson: req.Parameters, - }, model.WithAutoGenRespSchema()) + }, opts...) if err != nil { var e errorx.StatusError if errors.As(err, &e) { diff --git a/backend/domain/agent/singleagent/internal/agentflow/node_tool_plugin.go b/backend/domain/agent/singleagent/internal/agentflow/node_tool_plugin.go index 48352857..8680d3d2 100644 --- a/backend/domain/agent/singleagent/internal/agentflow/node_tool_plugin.go +++ b/backend/domain/agent/singleagent/internal/agentflow/node_tool_plugin.go @@ -121,6 +121,7 @@ func (p *pluginInvokableTool) InvokableRun(ctx context.Context, argumentsInJSON } opts := []pluginEntity.ExecuteToolOpt{ + plugin.WithInvalidRespProcessStrategy(plugin.InvalidResponseProcessStrategyOfReturnDefault), plugin.WithToolVersion(p.toolInfo.GetVersion()), plugin.WithProjectInfo(p.projectInfo), } diff --git a/backend/domain/agent/singleagent/internal/agentflow/node_tool_pre_retriever.go b/backend/domain/agent/singleagent/internal/agentflow/node_tool_pre_retriever.go index 6ad67ddc..562af971 100644 --- a/backend/domain/agent/singleagent/internal/agentflow/node_tool_pre_retriever.go +++ b/backend/domain/agent/singleagent/internal/agentflow/node_tool_pre_retriever.go @@ -69,6 +69,7 @@ func (pr *toolPreCallConf) toolPreRetrieve(ctx context.Context, ar *AgentRequest } opts := []pluginEntity.ExecuteToolOpt{ + plugin.WithInvalidRespProcessStrategy(plugin.InvalidResponseProcessStrategyOfReturnDefault), plugin.WithProjectInfo(&plugin.ProjectInfo{ ProjectID: ar.Identity.AgentID, ProjectType: plugin.ProjectTypeOfAgent, diff --git a/backend/domain/plugin/service/exec_tool.go b/backend/domain/plugin/service/exec_tool.go index 8ec69b50..9c2980cd 100644 --- a/backend/domain/plugin/service/exec_tool.go +++ b/backend/domain/plugin/service/exec_tool.go @@ -1037,33 +1037,33 @@ func (t *toolExecutor) processResponse(ctx context.Context, rawResp string) (tri return "", nil } - // FIXME: trimming is a weak dependency function and does not affect the response - var trimmedRespMap map[string]any switch t.invalidRespProcessStrategy { case model.InvalidResponseProcessStrategyOfReturnRaw: trimmedRespMap, err = t.processWithInvalidRespProcessStrategyOfReturnRaw(ctx, respMap, schemaVal) if err != nil { - logs.CtxErrorf(ctx, "processWithInvalidRespProcessStrategyOfReturnRaw failed, err=%v", err) - return rawResp, nil + return "", err } case model.InvalidResponseProcessStrategyOfReturnDefault: trimmedRespMap, err = t.processWithInvalidRespProcessStrategyOfReturnDefault(ctx, respMap, schemaVal) if err != nil { - logs.CtxErrorf(ctx, "processWithInvalidRespProcessStrategyOfReturnDefault failed, err=%v", err) - return rawResp, nil + return "", err + } + + case model.InvalidResponseProcessStrategyOfReturnErr: + trimmedRespMap, err = t.processWithInvalidRespProcessStrategyOfReturnErr(ctx, respMap, schemaVal) + if err != nil { + return "", err } default: - logs.CtxErrorf(ctx, "invalid response process strategy '%d'", t.invalidRespProcessStrategy) - return rawResp, nil + return rawResp, fmt.Errorf("invalid response process strategy '%d'", t.invalidRespProcessStrategy) } trimmedResp, err = sonic.MarshalString(trimmedRespMap) if err != nil { - logs.CtxErrorf(ctx, "marshal trimmed response failed, err=%v", err) - return rawResp, nil + return "", errorx.Wrapf(err, "marshal trimmed response failed") } return trimmedResp, nil @@ -1095,6 +1095,115 @@ func (t *toolExecutor) processWithInvalidRespProcessStrategyOfReturnRaw(ctx cont return paramVals, nil } +func (t *toolExecutor) processWithInvalidRespProcessStrategyOfReturnErr(_ context.Context, paramVals map[string]any, paramSchema *openapi3.Schema) (map[string]any, error) { + var processor func(paramName string, paramVal any, schemaVal *openapi3.Schema) (any, error) + processor = func(paramName string, paramVal any, schemaVal *openapi3.Schema) (any, error) { + switch schemaVal.Type { + case openapi3.TypeObject: + newParamValMap := map[string]any{} + paramValMap, ok := paramVal.(map[string]any) + if !ok { + return nil, errorx.New(errno.ErrPluginExecuteToolFailed, errorx.KVf(errno.PluginMsgKey, + "expected '%s' to be of type 'object', but got '%T'", paramName, paramVal)) + } + + for paramName_, paramVal_ := range paramValMap { + paramSchema_, ok := schemaVal.Properties[paramName] + if !ok || t.disabledParam(paramSchema_.Value) { // Only the object field can be disabled, and the top level of request and response must be the object structure + continue + } + newParamVal, err := processor(paramName_, paramVal_, paramSchema_.Value) + if err != nil { + return nil, err + } + newParamValMap[paramName_] = newParamVal + } + + return newParamValMap, nil + + case openapi3.TypeArray: + newParamValSlice := []any{} + paramValSlice, ok := paramVal.([]any) + if !ok { + return nil, errorx.New(errno.ErrPluginExecuteToolFailed, errorx.KVf(errno.PluginMsgKey, + "expected '%s' to be of type 'array', but got '%T'", paramName, paramVal)) + } + + for _, paramVal_ := range paramValSlice { + newParamVal, err := processor(paramName, paramVal_, schemaVal.Items.Value) + if err != nil { + return nil, err + } + if newParamVal != nil { + newParamValSlice = append(newParamValSlice, newParamVal) + } + } + + return newParamValSlice, nil + + case openapi3.TypeString: + paramValStr, ok := paramVal.(string) + if !ok { + return nil, errorx.New(errno.ErrPluginExecuteToolFailed, errorx.KVf(errno.PluginMsgKey, + "expected '%s' to be of type 'string', but got '%T'", paramName, paramVal)) + } + + return paramValStr, nil + + case openapi3.TypeBoolean: + paramValBool, ok := paramVal.(bool) + if !ok { + return false, fmt.Errorf("expected '%s' to be of type 'boolean', but got '%T'", paramName, paramVal) + } + + return paramValBool, nil + + case openapi3.TypeInteger: + paramValNum, ok := paramVal.(json.Number) + if !ok { + return nil, errorx.New(errno.ErrPluginExecuteToolFailed, errorx.KVf(errno.PluginMsgKey, + "expected '%s' to be of type 'integer', but got '%T'", paramName, paramVal)) + } + paramValInt, err := paramValNum.Int64() + if err != nil { + return nil, errorx.New(errno.ErrPluginExecuteToolFailed, errorx.KVf(errno.PluginMsgKey, + "expected '%s' to be of type 'integer', but got '%T'", paramName, paramVal)) + } + + return paramValInt, nil + + case openapi3.TypeNumber: + paramValNum, ok := paramVal.(json.Number) + if !ok { + return nil, errorx.New(errno.ErrPluginExecuteToolFailed, errorx.KVf(errno.PluginMsgKey, + "expected '%s' to be of type 'number', but got '%T'", paramName, paramVal)) + } + + return paramValNum, nil + + default: + return nil, fmt.Errorf("unsupported type '%s'", schemaVal.Type) + } + } + + newParamVals := make(map[string]any, len(paramVals)) + for paramName, paramVal_ := range paramVals { + paramSchema_, ok := paramSchema.Properties[paramName] + if !ok || t.disabledParam(paramSchema_.Value) { + continue + } + + newParamVal, err := processor(paramName, paramVal_, paramSchema_.Value) + if err != nil { + return nil, err + } + + newParamVals[paramName] = newParamVal + } + + return newParamVals, nil +} + func (t *toolExecutor) processWithInvalidRespProcessStrategyOfReturnDefault(_ context.Context, paramVals map[string]any, paramSchema *openapi3.Schema) (map[string]any, error) { var processor func(paramVal any, schemaVal *openapi3.Schema) (any, error) processor = func(paramVal any, schemaVal *openapi3.Schema) (any, error) { @@ -1156,9 +1265,13 @@ func (t *toolExecutor) processWithInvalidRespProcessStrategyOfReturnDefault(_ co return paramValBool, nil case openapi3.TypeInteger: - paramValInt, ok := paramVal.(float64) + paramValNum, ok := paramVal.(json.Number) if !ok { - return float64(0), nil + return int64(0), nil + } + paramValInt, err := paramValNum.Int64() + if err != nil { + return int64(0), nil } return paramValInt, nil diff --git a/backend/domain/plugin/service/exec_tool_test.go b/backend/domain/plugin/service/exec_tool_test.go new file mode 100644 index 00000000..3911e62b --- /dev/null +++ b/backend/domain/plugin/service/exec_tool_test.go @@ -0,0 +1,252 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package service + +import ( + "bytes" + "encoding/json" + "errors" + "testing" + + "github.com/bytedance/mockey" + "github.com/bytedance/sonic" + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/assert" + + "github.com/coze-dev/coze-studio/backend/pkg/errorx" + + model "github.com/coze-dev/coze-studio/backend/api/model/crossdomain/plugin" +) + +func TestToolExecutorProcessWithInvalidRespProcessStrategyOfReturnDefault(t *testing.T) { + executor := &toolExecutor{ + invalidRespProcessStrategy: model.InvalidResponseProcessStrategyOfReturnDefault, + } + + paramVal := ` +{ + "a1": 1, + "b1": { + "a2": 2.1 + }, + "c1": [ + { + "a2": 3.1 + } + ], + "d1": "hello", + "f1": true +} +` + + decoder := sonic.ConfigDefault.NewDecoder(bytes.NewBufferString(paramVal)) + decoder.UseNumber() + paramValMap := map[string]any{} + err := decoder.Decode(¶mValMap) + assert.NoError(t, err) + + paramSchema := &openapi3.Schema{ + Type: openapi3.TypeObject, + Properties: map[string]*openapi3.SchemaRef{ + "a1": { + Value: &openapi3.Schema{ + Type: openapi3.TypeInteger, + }, + }, + "b1": { + Value: &openapi3.Schema{ + Type: openapi3.TypeObject, + Properties: map[string]*openapi3.SchemaRef{ + "a2": { + Value: &openapi3.Schema{ + Type: openapi3.TypeNumber, + }, + }, + }, + }, + }, + "c1": { + Value: &openapi3.Schema{ + Type: openapi3.TypeArray, + Items: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: openapi3.TypeObject, + Properties: map[string]*openapi3.SchemaRef{ + "a2": { + Value: &openapi3.Schema{ + Type: openapi3.TypeNumber, + }, + }, + }, + }, + }, + }, + }, + "d1": { + Value: &openapi3.Schema{ + Type: openapi3.TypeString, + }, + }, + "f1": { + Value: &openapi3.Schema{ + Type: openapi3.TypeBoolean, + }, + }, + }, + } + + processedParamValMap, err := executor.processWithInvalidRespProcessStrategyOfReturnDefault(nil, paramValMap, paramSchema) + assert.NoError(t, err) + assert.NotNil(t, processedParamValMap) + assert.Equal(t, int64(1), processedParamValMap["a1"]) + assert.Equal(t, json.Number("2.1"), processedParamValMap["b1"].(map[string]any)["a2"]) + assert.Equal(t, json.Number("3.1"), processedParamValMap["c1"].([]any)[0].(map[string]any)["a2"]) + assert.Equal(t, "hello", processedParamValMap["d1"]) + assert.Equal(t, true, processedParamValMap["f1"]) +} + +func TestToolExecutorProcessWithInvalidRespProcessStrategyOfReturnErr(t *testing.T) { + executor := &toolExecutor{ + invalidRespProcessStrategy: model.InvalidResponseProcessStrategyOfReturnErr, + } + + mockey.PatchConvey("integer", t, func() { + paramVal := ` +{ + "a": 1 +} +` + + decoder := sonic.ConfigDefault.NewDecoder(bytes.NewBufferString(paramVal)) + decoder.UseNumber() + paramValMap := map[string]any{} + err := decoder.Decode(¶mValMap) + assert.NoError(t, err) + + paramSchema := &openapi3.Schema{ + Type: openapi3.TypeObject, + Properties: map[string]*openapi3.SchemaRef{ + "a": { + Value: &openapi3.Schema{ + Type: openapi3.TypeString, + }, + }, + }, + } + _, err = executor.processWithInvalidRespProcessStrategyOfReturnErr(nil, paramValMap, paramSchema) + var customErr errorx.StatusError + assert.True(t, errors.As(err, &customErr)) + assert.Equal(t, "execute tool failed : expected 'a' to be of type 'string', but got 'json.Number'", customErr.Msg()) + + paramSchema = &openapi3.Schema{ + Type: openapi3.TypeObject, + Properties: map[string]*openapi3.SchemaRef{ + "a1": { + Value: &openapi3.Schema{ + Type: openapi3.TypeInteger, + }, + }, + }, + } + _, err = executor.processWithInvalidRespProcessStrategyOfReturnErr(nil, paramValMap, paramSchema) + assert.NoError(t, err) + }) + + mockey.PatchConvey("string", t, func() { + paramVal := ` +{ + "a": "1" +} +` + + decoder := sonic.ConfigDefault.NewDecoder(bytes.NewBufferString(paramVal)) + decoder.UseNumber() + paramValMap := map[string]any{} + err := decoder.Decode(¶mValMap) + assert.NoError(t, err) + + paramSchema := &openapi3.Schema{ + Type: openapi3.TypeObject, + Properties: map[string]*openapi3.SchemaRef{ + "a": { + Value: &openapi3.Schema{ + Type: openapi3.TypeInteger, + }, + }, + }, + } + _, err = executor.processWithInvalidRespProcessStrategyOfReturnErr(nil, paramValMap, paramSchema) + var customErr errorx.StatusError + assert.True(t, errors.As(err, &customErr)) + assert.Equal(t, "execute tool failed : expected 'a' to be of type 'integer', but got 'string'", customErr.Msg()) + + paramSchema = &openapi3.Schema{ + Type: openapi3.TypeObject, + Properties: map[string]*openapi3.SchemaRef{ + "a": { + Value: &openapi3.Schema{ + Type: openapi3.TypeString, + }, + }, + }, + } + _, err = executor.processWithInvalidRespProcessStrategyOfReturnErr(nil, paramValMap, paramSchema) + assert.NoError(t, err) + }) + + mockey.PatchConvey("boolean", t, func() { + paramVal := ` +{ + "a": false +} +` + + decoder := sonic.ConfigDefault.NewDecoder(bytes.NewBufferString(paramVal)) + decoder.UseNumber() + paramValMap := map[string]any{} + err := decoder.Decode(¶mValMap) + assert.NoError(t, err) + + paramSchema := &openapi3.Schema{ + Type: openapi3.TypeObject, + Properties: map[string]*openapi3.SchemaRef{ + "a": { + Value: &openapi3.Schema{ + Type: openapi3.TypeString, + }, + }, + }, + } + _, err = executor.processWithInvalidRespProcessStrategyOfReturnErr(nil, paramValMap, paramSchema) + var customErr errorx.StatusError + assert.True(t, errors.As(err, &customErr)) + assert.Equal(t, "execute tool failed : expected 'a' to be of type 'string', but got 'bool'", customErr.Msg()) + + paramSchema = &openapi3.Schema{ + Type: openapi3.TypeObject, + Properties: map[string]*openapi3.SchemaRef{ + "a": { + Value: &openapi3.Schema{ + Type: openapi3.TypeBoolean, + }, + }, + }, + } + _, err = executor.processWithInvalidRespProcessStrategyOfReturnErr(nil, paramValMap, paramSchema) + assert.NoError(t, err) + }) +}