204 lines
8.6 KiB
Go
204 lines
8.6 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// 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 cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// ToolSearchV1Request is the stable stdin transport used by Agent hosts. The
|
|
// external ranking is untrusted input and is revalidated against the
|
|
// declaration-assembled Catalog before it can influence the result.
|
|
type ToolSearchV1Request struct {
|
|
Version string `json:"version"`
|
|
Query string `json:"query"`
|
|
Subqueries []string `json:"subqueries,omitempty"`
|
|
Limit int `json:"limit,omitempty"`
|
|
CandidateLimit int `json:"candidate_limit,omitempty"`
|
|
ProductIDs []string `json:"product_ids,omitempty"`
|
|
Effects []string `json:"effects,omitempty"`
|
|
ExcludeCanonical []string `json:"exclude_canonical,omitempty"`
|
|
ExternalRanking *ExternalCandidateRanking `json:"external_ranking,omitempty"`
|
|
}
|
|
|
|
type suppliedToolSearchRanking struct {
|
|
ranking ExternalCandidateRanking
|
|
}
|
|
|
|
var schemaSearchNewEngine = NewDeliveryToolSearchEngine
|
|
|
|
func (p suppliedToolSearchRanking) Retrieve(context.Context, ToolSearchCandidateRequest) (ExternalCandidateRanking, error) {
|
|
return p.ranking, nil
|
|
}
|
|
|
|
func newSchemaSearchCommand() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "search",
|
|
Short: "按自然语言搜索可用命令 Schema",
|
|
Long: `按自然语言或精确身份检索 ToolReference。
|
|
|
|
输出固定为完整、紧凑的 tool-search.v1 JSON envelope。为保证版本、Catalog
|
|
绑定和响应字节预算可验证,本命令不支持 --fields、--jq 或非 JSON --format。`,
|
|
Example: ` dws schema search --query "查询群消息已读状态"
|
|
printf '%s' '{"version":"tool-search.v1","query":"发群文件并确认已读","subqueries":["给群里发送文件消息","查询群消息已读状态"]}' |
|
|
dws schema search --request-json -`,
|
|
Args: cobra.NoArgs,
|
|
DisableAutoGenTag: true,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
if err := validateSchemaSearchOutputFlags(cmd); err != nil {
|
|
return err
|
|
}
|
|
request, err := schemaSearchRequest(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var provider ToolSearchCandidateProvider
|
|
if request.ExternalRanking != nil {
|
|
provider = suppliedToolSearchRanking{ranking: *request.ExternalRanking}
|
|
}
|
|
engine, err := schemaSearchNewEngine(provider)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
internal := ToolSearchRequest{
|
|
Query: request.Query,
|
|
Limit: request.Limit,
|
|
CandidateLimit: request.CandidateLimit,
|
|
ProductIDs: request.ProductIDs,
|
|
Effects: request.Effects,
|
|
ExcludeCanonicalPaths: request.ExcludeCanonical,
|
|
}
|
|
var response ToolSearchResponse
|
|
if len(request.Subqueries) > 0 {
|
|
response, err = engine.SearchSubqueries(cmd.Context(), request.Subqueries, internal)
|
|
} else {
|
|
response, err = engine.Search(cmd.Context(), internal)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// The response budget is defined over this compact wire encoding. Do
|
|
// not run it through pretty/table formatting or post-filtering because
|
|
// Agent hosts require one complete versioned envelope.
|
|
if err := json.NewEncoder(cmd.OutOrStdout()).Encode(response); err != nil {
|
|
return fmt.Errorf("encode tool-search.v1 response: %w", err)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().String("query", "", "自然语言意图或 canonical/CLI path")
|
|
cmd.Flags().Int("limit", defaultToolSearchLimit, "返回引用数量")
|
|
cmd.Flags().Int("candidate-limit", defaultToolSearchCandidateLimit, "每路召回候选数量")
|
|
cmd.Flags().StringSlice("product", nil, "限制产品 ID,可重复或逗号分隔")
|
|
cmd.Flags().StringSlice("effect", nil, "限制 effect,可重复或逗号分隔")
|
|
cmd.Flags().StringSlice("exclude", nil, "排除 canonical path,可重复或逗号分隔")
|
|
cmd.Flags().String("request-json", "", `从 stdin 读取 JSON;值必须为 -;必含 version="tool-search.v1",动作分解字段为 subqueries`)
|
|
return cmd
|
|
}
|
|
|
|
func validateSchemaSearchOutputFlags(cmd *cobra.Command) error {
|
|
for _, name := range []string{"fields", "jq"} {
|
|
if flag := cmd.Flag(name); flag != nil && flag.Changed {
|
|
return apperrors.NewValidation(
|
|
"schema search returns one complete tool-search.v1 envelope and does not support --"+name,
|
|
apperrors.WithReason("unsupported_output_filter"),
|
|
)
|
|
}
|
|
}
|
|
if flag := cmd.Flag("format"); flag != nil && flag.Changed && strings.TrimSpace(flag.Value.String()) != "json" {
|
|
return apperrors.NewValidation(
|
|
"schema search only supports --format json",
|
|
apperrors.WithReason("unsupported_output_format"),
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func schemaSearchRequest(cmd *cobra.Command) (ToolSearchV1Request, error) {
|
|
requestJSON, _ := cmd.Flags().GetString("request-json")
|
|
requestJSON = strings.TrimSpace(requestJSON)
|
|
if requestJSON != "" {
|
|
if requestJSON != "-" {
|
|
return ToolSearchV1Request{}, apperrors.NewValidation("--request-json only accepts - (stdin)", apperrors.WithReason("invalid_transport"))
|
|
}
|
|
for _, name := range []string{"query", "limit", "candidate-limit", "product", "effect", "exclude"} {
|
|
if cmd.Flags().Changed(name) {
|
|
return ToolSearchV1Request{}, apperrors.NewValidation("--request-json cannot be combined with --"+name, apperrors.WithReason("ambiguous_input"))
|
|
}
|
|
}
|
|
request, err := decodeToolSearchV1Request(cmd.InOrStdin())
|
|
if err != nil {
|
|
return ToolSearchV1Request{}, err
|
|
}
|
|
return validateToolSearchV1Request(request)
|
|
}
|
|
query, _ := cmd.Flags().GetString("query")
|
|
limit, _ := cmd.Flags().GetInt("limit")
|
|
candidateLimit, _ := cmd.Flags().GetInt("candidate-limit")
|
|
products, _ := cmd.Flags().GetStringSlice("product")
|
|
effects, _ := cmd.Flags().GetStringSlice("effect")
|
|
excluded, _ := cmd.Flags().GetStringSlice("exclude")
|
|
return validateToolSearchV1Request(ToolSearchV1Request{
|
|
Version: "tool-search.v1",
|
|
Query: query,
|
|
Limit: limit,
|
|
CandidateLimit: candidateLimit,
|
|
ProductIDs: products,
|
|
Effects: effects,
|
|
ExcludeCanonical: excluded,
|
|
})
|
|
}
|
|
|
|
func decodeToolSearchV1Request(reader io.Reader) (ToolSearchV1Request, error) {
|
|
payload, err := io.ReadAll(io.LimitReader(reader, maxToolSearchRequestBytes+1))
|
|
if err != nil {
|
|
return ToolSearchV1Request{}, apperrors.NewValidation("read tool search request: "+err.Error(), apperrors.WithReason("read_request_failed"))
|
|
}
|
|
if len(payload) > maxToolSearchRequestBytes {
|
|
return ToolSearchV1Request{}, apperrors.NewValidation(fmt.Sprintf("tool search request exceeds %d bytes", maxToolSearchRequestBytes), apperrors.WithReason("request_too_large"))
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(payload))
|
|
decoder.DisallowUnknownFields()
|
|
var request ToolSearchV1Request
|
|
if err := decoder.Decode(&request); err != nil {
|
|
return ToolSearchV1Request{}, apperrors.NewValidation("decode tool-search.v1 request: "+err.Error(), apperrors.WithReason("invalid_request_json"))
|
|
}
|
|
var trailing any
|
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
|
if err == nil {
|
|
return ToolSearchV1Request{}, apperrors.NewValidation("tool search request must contain exactly one JSON object", apperrors.WithReason("invalid_request_json"))
|
|
}
|
|
return ToolSearchV1Request{}, apperrors.NewValidation("decode trailing tool search input: "+err.Error(), apperrors.WithReason("invalid_request_json"))
|
|
}
|
|
return request, nil
|
|
}
|
|
|
|
func validateToolSearchV1Request(request ToolSearchV1Request) (ToolSearchV1Request, error) {
|
|
if request.Version != "tool-search.v1" {
|
|
return ToolSearchV1Request{}, apperrors.NewValidation(`tool search request must include "version":"tool-search.v1"; action decomposition uses the "subqueries" array`, apperrors.WithReason("unsupported_version"))
|
|
}
|
|
if len(request.Subqueries) > 0 && request.ExternalRanking != nil {
|
|
return ToolSearchV1Request{}, apperrors.NewValidation("external_ranking is action-sized and cannot be combined with subqueries", apperrors.WithReason("external_ranking_with_subqueries"))
|
|
}
|
|
return request, nil
|
|
}
|