Files
zero/internal/tools/deferred.go
KRATOS 2ea36cf138 Deferred (lazy) tool loading: tool_search + auto threshold for large MCP tool sets (#148)
* Deferred (lazy) tool loading: tool_search + auto threshold for large MCP tool sets

Adds deferred tool loading so a large MCP tool set no longer sends every tool's full JSON schema each turn. When the number of visible MCP tools crosses a threshold (config tools.deferThreshold, default 10; 0 disables), their schemas are withheld and advertised compactly in a per-turn system-reminder; the model pulls a tool's full schema on demand via a new tool_search tool. Strictly additive: below the threshold behaviour is byte-identical to today. No new module dependencies.

- Eligibility: MCP tools opt in via an optional Deferred() bool interface (tools.IsDeferred); built-ins stay eager. No change to the core Tool interface.
- Formatting (internal/tools/deferred.go): compact one-liners wrapped in a <system-reminder> by BuildDeferredReminder; server label via MCPServerName().
- tool_search (internal/tools/tool_search.go): SideEffectNone/PermissionAllow/AdvertiseInAuto; select:Name1,Name2 (exact) or keyword (ranked); returns full schemas and signals the loop via Meta["load_tools"]. Operator-filter-aware (never lists tools hidden by --enabled-tools/--disabled-tools).
- Agent loop: partitionTools is the single source of truth; eligible counts only ToolVisible deferred tools; Meta["load_tools"] lifts to ToolResult.LoadedTools and unions into a per-run loaded-set advertised next turn. Reminder appended only to the per-turn request copy (never persisted), including both reactive-compaction retry paths. tool_search stays reachable when deferral is active (exempt from the EnabledTools allowlist).
- Config + wiring: tools.deferThreshold (default 10; 0 disables; negative rejected); tool_search registered and DeferThreshold threaded in exec and TUI, gated on the same visible-deferred count the loop uses.

Recreated on top of current main; resolves the SideEffectNone clash with #146 (escalate_model). Supersedes #147.

* deferred-tools: activate deferral only when tool_search is runnable; drop dead deferredTools()

Addresses #148 review:
- partitionTools gates 'active' on a registered, non-disabled, mode-advertised
  tool_search (mirrors the executeToolCall dispatch gate). When the loader is
  unavailable (explicitly disabled, missing, or not advertised e.g. spec-draft),
  fall back to the eager/inactive path instead of hiding deferred tools behind a
  loader the model can never call (the dead-end).
- Flip TestDisabledToolSearch... to assert the eager fallback; register a real
  tool_search in the active-path tests that previously relied on activation
  without a usable loader.
- Remove unused toolSearchTool.deferredTools().

* deferred-tools: disable deferral in exec when tool_search disabled; drop unused toolDefinitions shim

Further #148 review:
- exec computes one effective DeferThreshold, forced to 0 when tool_search is in
  --disabled-tools, and feeds it to both registerToolSearchIfEligible and
  agent.Options.DeferThreshold — so the registration gate and the loop partition
  gate agree, and a disabled loader never enables deferral.
- Remove the unused toolDefinitions shim (golangci-lint unused; no callers).
2026-06-09 12:20:22 +05:30

210 lines
6.9 KiB
Go

package tools
import (
"fmt"
"regexp"
"sort"
"strings"
)
const (
defaultShortenMax = 200
maxSchemaHintParams = 4
maxSchemaHintLen = 360
)
var (
headingPrefix = regexp.MustCompile(`^#{1,6}\s+(.+)$`)
genericHeading = regexp.MustCompile(`(?i)^(overview|description|summary)$`)
collapseWhitespace = regexp.MustCompile(`\s+`)
)
// normalizeDescriptionLine trims a line and unwraps a leading markdown heading.
func normalizeDescriptionLine(line string) string {
trimmed := strings.TrimSpace(line)
if m := headingPrefix.FindStringSubmatch(trimmed); m != nil {
return strings.TrimSpace(m[1])
}
return trimmed
}
func isGenericDescriptionHeading(line string) bool {
return genericHeading.MatchString(line)
}
// truncateDescription clips desc to at most max runes, preferring a word
// boundary and appending a single-rune ellipsis when it had to cut.
func truncateDescription(desc string, max int) string {
runes := []rune(desc)
if max <= 0 || len(runes) <= max {
return desc
}
cut := string(runes[:max-1])
if idx := strings.LastIndexByte(cut, ' '); idx > 0 {
cut = cut[:idx]
}
return strings.TrimRight(cut, " ") + "…"
}
// shortenDescription reduces desc to a single meaningful line, collapses
// whitespace, and truncates to at most max runes with an ellipsis.
func shortenDescription(desc string, max int) string {
if desc == "" {
return ""
}
if max <= 0 {
max = defaultShortenMax
}
var lines []string
for _, raw := range strings.Split(desc, "\n") {
if line := normalizeDescriptionLine(raw); line != "" {
lines = append(lines, collapseWhitespace.ReplaceAllString(line, " "))
}
}
if len(lines) == 0 {
return ""
}
meaningful := lines[0]
if isGenericDescriptionHeading(meaningful) && len(lines) > 1 {
meaningful = meaningful + " — " + lines[1]
}
return truncateDescription(meaningful, max)
}
// formatInputSchemaHint renders a one-line summary of a tool's input schema,
// e.g. "inputs (* required): a (string)*, b (number); +N more". Property names
// are sorted for deterministic output (Schema.Properties is a map). Returns
// "(none)" when the schema declares no properties. At most maxSchemaHintParams
// params are shown; the rest are summarized as "; +N more".
func formatInputSchemaHint(schema Schema) string {
if len(schema.Properties) == 0 {
return "(none)"
}
names := make([]string, 0, len(schema.Properties))
for name := range schema.Properties {
names = append(names, name)
}
sort.Strings(names)
required := make(map[string]bool, len(schema.Required))
for _, name := range schema.Required {
required[name] = true
}
shown := names
if len(shown) > maxSchemaHintParams {
shown = shown[:maxSchemaHintParams]
}
parts := make([]string, 0, len(shown))
for _, name := range shown {
prop := schema.Properties[name]
marker := ""
if required[name] {
marker = "*"
}
typePart := ""
if t := strings.TrimSpace(prop.Type); t != "" {
typePart = " (" + t + ")"
}
parts = append(parts, name+typePart+marker)
}
more := ""
if len(names) > maxSchemaHintParams {
more = fmt.Sprintf("; +%d more", len(names)-maxSchemaHintParams)
}
hint := "inputs (* required): " + strings.Join(parts, ", ") + more
return shortenDescription(hint, maxSchemaHintLen)
}
// mcpToolNamePrefix mirrors the prefix used by mcp.registryToolName.
const mcpToolNamePrefix = "mcp_"
// mcpServerFromToolName extracts the server token from a synthesized MCP tool
// name produced by mcp.registryToolName ("mcp_<server>_<tool>"). It returns ""
// for non-MCP names and for names that lack both a server and a tool segment.
func mcpServerFromToolName(name string) string {
rest, ok := strings.CutPrefix(name, mcpToolNamePrefix)
if !ok {
return ""
}
sep := strings.IndexByte(rest, '_')
if sep <= 0 || sep == len(rest)-1 {
// No server token, or nothing after the server token (no tool part).
return ""
}
return rest[:sep]
}
// formatDeferredToolLine renders a single compact advertisement line for a
// deferred tool: "name: <short-desc> | server: <X> | <input-hint>". The
// "server: <X>" segment is omitted when server is empty (non-MCP tools).
func formatDeferredToolLine(name, description, server string, schema Schema) string {
desc := shortenDescription(description, defaultShortenMax)
if desc == "" {
desc = "No description provided"
}
parts := []string{name + ": " + desc}
if server != "" {
parts = append(parts, "server: "+server)
}
parts = append(parts, formatInputSchemaHint(schema))
return strings.Join(parts, " | ")
}
// mcpServerNamed is an optional interface a deferred MCP tool implements to
// report its true (un-sanitized-token) server name for the reminder label. When
// a tool provides it, DeferredLine prefers it over the name-derived token, which
// would mislabel a server whose sanitized name itself contains an underscore
// (e.g. "git_hub" → "git"). It affects the cosmetic reminder label only; tool
// resolution never depends on this.
type mcpServerNamed interface {
MCPServerName() string
}
// DeferredLine renders the compact advertisement line for a single deferred
// tool, deriving the MCP server label from the tool's reported server name when
// available, falling back to the token parsed from the tool's name. It is the
// exported entry point the agent loop uses to build each line of the
// deferred-tools reminder, so callers in other packages never touch the
// unexported formatters.
func DeferredLine(t Tool) string {
server := mcpServerFromToolName(t.Name())
if named, ok := t.(mcpServerNamed); ok {
if reported := strings.TrimSpace(named.MCPServerName()); reported != "" {
server = reported
}
}
return formatDeferredToolLine(t.Name(), t.Description(), server, t.Parameters())
}
const (
systemReminderStart = "<system-reminder>"
systemReminderEnd = "</system-reminder>"
)
// BuildDeferredReminder wraps the given deferred-tool advertisement lines in a
// <system-reminder> block instructing the model to load a tool's full schema
// via tool_search (using a `select:Name1,Name2` exact query or keywords)
// before calling it. Returns "" when there are no lines.
func BuildDeferredReminder(lines []string) string {
if len(lines) == 0 {
return ""
}
var b strings.Builder
b.WriteString(systemReminderStart)
b.WriteString("\n")
b.WriteString("The tools listed below are available in this environment, but their full schemas are omitted from the current tool list to save context.\n")
b.WriteString(`Call tool_search with query "select:Name1,Name2" (the exact names before each colon) or with keywords to load a tool's full schema before calling it. Calling an omitted tool directly will fail with an input validation error.` + "\n")
b.WriteString("Do not call tool_search for tools already present in the current tool list, and do not guess or invent tool names.\n")
b.WriteString("\n")
b.WriteString("Deferred tools:\n")
b.WriteString(strings.Join(lines, "\n"))
b.WriteString("\n")
b.WriteString(systemReminderEnd)
return b.String()
}