Files
zero/internal/tools/tool_search.go
Anandan 1fde941803 perf: reduce startup and turn overhead (#932)
* perf(tui): disable duplicate composer cursor blink

* perf(tui): stop composer wakeups after idle

* refactor(tools): publish immutable registry snapshots

* perf(startup): defer optional tool discovery

* perf(provider): reuse compatible response sessions

* feat(provider): add native patch tool input

* fix(provider): recognize saved chat sessions

* perf(tui): render only on screen changes

* fix(runtime): address performance review findings

* fix(usage): expose cache efficiency details

* perf(agent): reduce redundant bounded-task turns

* fix: harden runtime review edge cases

* fix: address runtime review findings

* build: restore official Bubble Tea dependency

* fix(cli): redact MCP registration errors

* fix: harden turn session regressions

* fix: keep run tools and patch roots consistent

* fix: bind freeform patch writes to scoped roots

* test: keep patch fixtures out of worktree

* fix(tools): reject mixed-root freeform patches
2026-08-26 13:22:05 +08:00

437 lines
15 KiB
Go

package tools
import (
"context"
"encoding/json"
"sort"
"strconv"
"strings"
)
// ToolSearchToolName is the canonical registry name of the deferred-tool loader.
// It is exported so the agent loop, CLI filter validation, and the partition can
// reference the loader without re-stating the literal (keeping all the gates that
// must treat it specially in agreement).
const ToolSearchToolName = "tool_search"
// toolSearchMaxKeywordMatches caps how many deferred tools a bare-keyword query
// loads in one call, keeping the returned schemas bounded.
const toolSearchMaxKeywordMatches = 10
// toolSearchTool lets the model pull a deferred tool's full schema on demand.
// It holds the live registry (like escalate_model holds the model registry) so
// it can resolve names against the currently registered deferred-eligible tools.
type toolSearchTool struct {
baseTool
registry *Registry
}
// NewToolSearchTool builds the tool_search tool over the given registry. The
// tool is informational/no-side-effect and is advertised even in auto mode so
// the model can always discover withheld tools.
func NewToolSearchTool(registry *Registry) Tool {
return toolSearchTool{
baseTool: baseTool{
name: ToolSearchToolName,
description: BuildToolSearchDescription(nil),
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"query": {
Type: "string",
Description: "Either \"select:Name1,Name2\" for exact tool names, or space-separated keywords to match tool names and descriptions.",
},
},
Required: []string{"query"},
AdditionalProperties: false,
},
safety: Safety{
SideEffect: SideEffectNone,
Permission: PermissionAllow,
Reason: "Lists and loads already-registered tool schemas; performs no side effects.",
AdvertiseInAuto: true,
},
// Reads registry schemas only; no workspace mutation.
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false},
},
registry: registry,
}
}
func (tool toolSearchTool) cloneForRegistry(registry *Registry) Tool {
tool.registry = registry
return tool
}
// Run satisfies the Tool interface; actual dispatch goes through RunWithOptions.
func (tool toolSearchTool) Run(ctx context.Context, args map[string]any) Result {
return tool.RunWithOptions(ctx, args, RunOptions{})
}
func (tool toolSearchTool) RunWithOptions(_ context.Context, args map[string]any, options RunOptions) Result {
query, err := stringArg(args, "query", "", true)
if err != nil {
return errorResult("Error: Invalid arguments for tool_search: " + err.Error())
}
query = strings.TrimSpace(query)
// Honor the run's operator filters AND the run's permission-mode visibility
// so an operator-hidden OR mode-hidden deferred tool is invisible to
// tool_search: it never resolves via select:, never ranks for a keyword
// query, and is omitted from the no-match listing. The mode filter matters
// even though tool_search itself is denied at dispatch in plan/spec-draft
// today (its own Safety carries no side effect, so it fails the same
// advertisement gate) — it is defense-in-depth so a deferred write/mutator
// tool's name, description, and schema can never leak through this loader
// if that outer gate is ever loosened independently of this one.
deferred := tool.visibleDeferredTools(options.EnabledTools, options.DisabledTools, options.PermissionMode)
var matches []Tool
if rest, ok := strings.CutPrefix(query, "select:"); ok {
matches = tool.resolveExact(rest, deferred)
} else {
matches = tool.rankByKeyword(query, deferred)
}
// Names the model asked for that it ALREADY has eagerly. tool_search only
// loads DEFERRED tools, so a request for an eager tool (e.g. Task or
// update_plan) otherwise falls through to a misleading "no tools matched" —
// a common confusion that leaves weaker models looping. Redirect the model to
// call those tools directly instead.
alreadyAvailable := tool.eagerToolsForQuery(query, options.EnabledTools, options.DisabledTools, options.PermissionMode)
if len(matches) == 0 {
if len(alreadyAvailable) > 0 {
return okResult(alreadyAvailableMessage(alreadyAvailable))
}
return okResult(tool.noMatchMessage(query, deferred))
}
names := make([]string, 0, len(matches))
for _, match := range matches {
names = append(names, match.Name())
}
output := renderLoadedTools(matches)
if len(alreadyAvailable) > 0 {
output += "\n\n" + alreadyAvailableMessage(alreadyAvailable)
}
return Result{
Status: StatusOK,
Output: output,
Meta: map[string]string{"load_tools": strings.Join(names, ",")},
}
}
// visibleDeferredTools returns the registry's deferred-eligible tools that pass
// the operator allow/deny filters and the run's permission-mode visibility,
// sorted by name so keyword ranking and listings stay deterministic. A
// nil/empty filter pair admits every deferred tool on the operator axis (the
// pre-filter behavior); permissionMode "" admits every deferred tool on the
// mode axis (matches every mode but plan/spec-draft, which never restricted
// tool_search's candidates before this filter existed). The allow/deny
// semantics mirror the agent's ToolAllowedByFilters: denied if in disabled; if
// enabled is non-empty, the tool must be listed in it.
func (tool toolSearchTool) visibleDeferredTools(enabled []string, disabled []string, permissionMode string) []Tool {
var deferred []Tool
if tool.registry != nil {
for _, candidate := range tool.registry.All() {
if !ModelVisible(candidate) {
continue
}
if !IsDeferred(candidate) {
continue
}
if !toolAllowedByFilters(candidate.Name(), enabled, disabled) {
continue
}
if !toolAdvertisedForPermissionMode(candidate, permissionMode) {
continue
}
deferred = append(deferred, candidate)
}
}
sort.Slice(deferred, func(left, right int) bool {
return deferred[left].Name() < deferred[right].Name()
})
return deferred
}
// visibleEagerToolNames returns the names of tools the model ALREADY has in its
// list this run: registered, NOT deferred, passing the operator filters and the
// run's permission-mode visibility, and not tool_search itself. These never
// need (and never resolve through) tool_search.
func (tool toolSearchTool) visibleEagerToolNames(enabled []string, disabled []string, permissionMode string) map[string]bool {
names := map[string]bool{}
if tool.registry == nil {
return names
}
for _, candidate := range tool.registry.All() {
if !ModelVisible(candidate) {
continue
}
if IsDeferred(candidate) || candidate.Name() == ToolSearchToolName {
continue
}
if !toolAllowedByFilters(candidate.Name(), enabled, disabled) {
continue
}
if !toolAdvertisedForPermissionMode(candidate, permissionMode) {
continue
}
names[candidate.Name()] = true
}
return names
}
// eagerToolsForQuery returns, sorted, the already-available (eager) tool names a
// tool_search query refers to: exact names for a "select:" query, or eager tools
// whose name matches a keyword otherwise. Used to steer the model back to calling
// a tool it already has instead of fruitlessly searching for it.
func (tool toolSearchTool) eagerToolsForQuery(query string, enabled []string, disabled []string, permissionMode string) []string {
eager := tool.visibleEagerToolNames(enabled, disabled, permissionMode)
if len(eager) == 0 {
return nil
}
hit := map[string]bool{}
if rest, ok := strings.CutPrefix(query, "select:"); ok {
for _, raw := range strings.Split(rest, ",") {
if name := strings.TrimSpace(raw); name != "" && eager[name] {
hit[name] = true
}
}
} else {
keywords := strings.Fields(strings.ToLower(query))
for name := range eager {
lower := strings.ToLower(name)
for _, keyword := range keywords {
if strings.Contains(lower, keyword) {
hit[name] = true
break
}
}
}
}
out := make([]string, 0, len(hit))
for name := range hit {
out = append(out, name)
}
sort.Strings(out)
return out
}
// alreadyAvailableMessage tells the model the named tools are already in its list
// and must be called directly — tool_search is only for deferred tools.
func alreadyAvailableMessage(names []string) string {
subject, verb := names[0], "is"
if len(names) > 1 {
subject, verb = strings.Join(names, ", "), "are"
}
return subject + " " + verb + " already in your tool list — call " +
map[bool]string{true: "them", false: "it"}[len(names) > 1] +
" directly. tool_search only loads deferred tools, not tools you already have."
}
// toolAllowedByFilters mirrors agent.ToolAllowedByFilters (kept here to avoid an
// import cycle: the agent package imports tools, not the other way around). A
// name is denied when it appears in disabled; when enabled is non-empty, the
// name must appear in it to be allowed.
func toolAllowedByFilters(name string, enabled []string, disabled []string) bool {
if len(enabled) > 0 && !containsName(enabled, name) {
return false
}
return !containsName(disabled, name)
}
// PlanMode and SpecDraftMode are the string values of agent.PermissionModePlan
// and agent.PermissionModeSpecDraft. RunOptions.PermissionMode is already a
// plain string (set from agent.PermissionMode via string(permissionMode)), so
// this package owns the literals rather than importing the agent type (which
// would create an import cycle). agent.ToolAdvertised delegates plan/spec-draft
// decisions here so tool_search and the dispatch gate stay identical.
const (
PlanMode = "plan"
SpecDraftMode = "spec-draft"
)
// ToolAdvertisedForPermissionMode is the single source of truth for whether a
// tool may be advertised under a given permission mode string. agent.ToolAdvertised
// calls this for plan/spec-draft after its own PermissionDeny short-circuit;
// tool_search applies it to deferred candidates so a mutator cannot leak through
// load_tools. Modes other than plan/spec-draft only apply the PermissionDeny
// gate here (auto/member-auto/ask/unsafe advertisement rules stay in agent).
func ToolAdvertisedForPermissionMode(tool Tool, permissionMode string) bool {
// A denied tool is never advertised in any mode (mirrors agent.ToolAdvertised).
if tool.Safety().Permission == PermissionDeny {
return false
}
switch permissionMode {
case PlanMode:
if tool.Name() == "lsp_navigate" {
return false
}
safety := tool.Safety()
return safety.SideEffect == SideEffectRead && safety.Permission == PermissionAllow
case SpecDraftMode:
// Never trust control-tool names alone (a re-registered spoof can carry
// arbitrary Safety).
safety := tool.Safety()
switch tool.Name() {
case "update_plan":
return false
case "ask_user":
return safety.SideEffect == SideEffectRead && safety.Permission == PermissionAllow
case "submit_spec":
return safety.SideEffect == SideEffectWrite && safety.Permission == PermissionAllow
}
return safety.SideEffect == SideEffectRead && safety.Permission == PermissionAllow
default:
return true
}
}
// toolAdvertisedForPermissionMode is the unexported name used by tool_search
// internals; keep it as a thin alias so call sites stay short.
func toolAdvertisedForPermissionMode(tool Tool, permissionMode string) bool {
return ToolAdvertisedForPermissionMode(tool, permissionMode)
}
func containsName(names []string, name string) bool {
for _, candidate := range names {
if candidate == name {
return true
}
}
return false
}
// resolveExact maps a comma-separated name list (the part after "select:") to
// the matching deferred tools, preserving the model's order and skipping blanks
// and unknown names.
func (tool toolSearchTool) resolveExact(list string, deferred []Tool) []Tool {
byName := make(map[string]Tool, len(deferred))
for _, candidate := range deferred {
byName[candidate.Name()] = candidate
}
var matches []Tool
seen := make(map[string]bool)
for _, raw := range strings.Split(list, ",") {
name := strings.TrimSpace(raw)
if name == "" || seen[name] {
continue
}
if candidate, ok := byName[name]; ok {
seen[name] = true
matches = append(matches, candidate)
}
}
return matches
}
// rankByKeyword scores deferred tools by case-insensitive substring match on the
// name (weighted higher) then the description, and returns the top matches.
func (tool toolSearchTool) rankByKeyword(query string, deferred []Tool) []Tool {
keywords := strings.Fields(strings.ToLower(query))
if len(keywords) == 0 {
return nil
}
type scored struct {
tool Tool
score int
order int
}
var ranked []scored
for index, candidate := range deferred {
name := strings.ToLower(candidate.Name())
desc := strings.ToLower(candidate.Description())
nameSquashed := squashSeparators(name)
score := 0
for _, keyword := range keywords {
switch {
case strings.Contains(name, keyword):
score += 2
case strings.Contains(nameSquashed, squashSeparators(keyword)):
// Separator-insensitive fallback: "webfetch" matches "web_fetch".
// Ranked below an exact substring so precise queries still win.
score++
}
if strings.Contains(desc, keyword) {
score++
}
}
if score > 0 {
ranked = append(ranked, scored{tool: candidate, score: score, order: index})
}
}
sort.SliceStable(ranked, func(left, right int) bool {
if ranked[left].score != ranked[right].score {
return ranked[left].score > ranked[right].score
}
return ranked[left].order < ranked[right].order
})
if len(ranked) > toolSearchMaxKeywordMatches {
ranked = ranked[:toolSearchMaxKeywordMatches]
}
matches := make([]Tool, 0, len(ranked))
for _, entry := range ranked {
matches = append(matches, entry.tool)
}
return matches
}
// squashSeparators drops separators so "web_fetch", "web-fetch", and "web fetch"
// all normalize to "webfetch". It lets a query missing the separators still match
// a tool name, the common way a model mistypes a tool ("webfetch").
func squashSeparators(s string) string {
return strings.Map(func(r rune) rune {
switch r {
case '_', '-', ' ', '.':
return -1
}
return r
}, s)
}
// noMatchMessage reports that nothing loaded and names the available deferred
// tools so the model can retry with a valid select: query.
func (tool toolSearchTool) noMatchMessage(query string, deferred []Tool) string {
if len(deferred) == 0 {
return "No deferred tools are available to load."
}
names := make([]string, 0, len(deferred))
for _, candidate := range deferred {
names = append(names, candidate.Name())
}
return "No tools matched \"" + query + "\". Available tools: " + strings.Join(names, ", ") +
`. Retry with query "select:Name1,Name2" using exact names.`
}
// renderLoadedTools lists each loaded tool's name, full description, and full
// input schema (pretty-printed JSON) so the model has the complete spec inline.
func renderLoadedTools(matches []Tool) string {
var builder strings.Builder
builder.WriteString("Loaded ")
if len(matches) == 1 {
builder.WriteString("1 tool")
} else {
builder.WriteString(strconv.Itoa(len(matches)))
builder.WriteString(" tools")
}
builder.WriteString(". Full schemas follow; call them on the next turn.\n")
for _, match := range matches {
builder.WriteString("\n## ")
builder.WriteString(match.Name())
builder.WriteString("\n")
builder.WriteString(match.Description())
builder.WriteString("\n")
schemaJSON, err := json.MarshalIndent(match.Parameters(), "", " ")
if err != nil {
builder.WriteString("(schema unavailable)\n")
continue
}
builder.WriteString("input schema:\n")
builder.Write(schemaJSON)
builder.WriteString("\n")
}
return builder.String()
}