Files
dingtalk-workspace-cli/internal/cli/schema_search_command.go
T

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
}