mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -10:00
* 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).
210 lines
6.9 KiB
Go
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()
|
|
}
|