Files
dingtalk-workspace-cli/internal/cli/schema_search_command.go
T
玉澜 225a073d89 fix(cli): close tool search self-review findings
- SearchSubqueries returned a nil candidate slice on the
  decomposed_exact_filtered refusal, so that one strategy encoded
  "candidates":null while every other path encoded []; the refusal now
  carries an empty slice and a marshal assertion pins the wire shape
- the memoized delivery engine was never invalidated: it indexes one
  Catalog generation and caches its source/surface hashes, but
  RegisterSchemaSourceRoot -> resetDeliverySchemaCatalogState left the
  memo installed, so a re-registered root could be served stale hashes.
  Catalog delivery reset now clears it, with a ForTest wrapper and a
  regression test for the rebuild
- dangerous filter Unicode is rejected before the effect vocabulary and
  Catalog product checks, so a control/Bidi character returns
  dangerous_filter_unicode instead of being echoed back through
  invalid_effect; the filter list is an ordered slice, not a map, so the
  reported field name is deterministic
- dws schema help and RFC 5.8 record the path-form boundary: multi-token
  args resolve as a CLI path, a canonical path must stay one argument
- --explain and ToolSearchScoreBreakdown declare that the reported score
  is the raw value while ordering uses the quantized key
- --limit/--candidate-limit help state that 0 means "use the default",
  which was already the behavior but read as an asymmetry against the
  rejected negative values
- bootstrap seeds become named constants and are published in both
  evaluation reports (bootstrap_samples/bootstrap_seed/
  recovery_bootstrap_seed) so an interval is reproducible without
  reading the source
- dingtalk-shared documents the cross-product summary --dry-run flag and
  that its command list is a plan, not business data
2026-08-17 17:21:10 +08:00

204 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
"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.
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"`
}
var schemaSearchNewEngine = NewDeliveryToolSearchEngineWithConfig
// schemaSearchEngineConfig resolves the search config the same way
// NewDeliveryToolSearchEngine does (defaults + DWS_TOOL_SEARCH_* env
// overrides), then lets an explicit --explain flag win over the env value.
func schemaSearchEngineConfig(cmd *cobra.Command) ToolSearchConfig {
config := ResolveToolSearchConfigFromEnv(DefaultToolSearchConfig())
if explain, _ := cmd.Flags().GetBool("explain"); explain {
config.Explain = true
}
return config
}
func newSchemaSearchCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "search",
Short: "按自然语言搜索可用命令 Schema",
Long: `按自然语言或精确身份检索 ToolReference。
输出固定为完整、紧凑的 tool-search.v1 JSON envelope。为保证版本、Catalog
绑定和响应字节预算可验证,本命令不支持 --fields、--jq 或非 JSON --format。
DWS 对 Agent 是一个元工具:已知子命令直接执行,未知子命令才使用 search。
选中候选后,必须使用 response catalog 的 source/surface hash Inspect canonical_path,
再按 primary_cli_path 执行;Search 结果本身不授权、不执行。`,
Example: ` dws schema search --query "查询群消息已读状态"
dws schema chat.query_msg_read_status --compact --format json \
--expected-source-hash "<search.catalog.source_hash>" \
--expected-surface-hash "<search.catalog.surface_hash>"
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
}
engine, err := schemaSearchNewEngine(schemaSearchEngineConfig(cmd))
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, fmt.Sprintf("返回引用数量(1-%d;0 表示沿用默认值 %d)", maxToolSearchLimit, defaultToolSearchLimit))
cmd.Flags().Int("candidate-limit", defaultToolSearchCandidateLimit, fmt.Sprintf("每路召回候选数量(1-%d;0 表示沿用默认值 %d)", maxToolSearchCandidateLimit, 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`)
cmd.Flags().Bool("explain", false, "开发诊断:输出 score_breakdown 含原始排序分(公开默认响应不含分数;仅用于离线调参,不建议线上 Agent 使用;会增大响应体积,建议配合 --limit 3)。score_breakdown.score 是未量化的原始分,排序键使用量化到 1e-9 的值,两者末位可能不同,不要用它复算候选顺序")
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"))
}
return request, nil
}