Files
zero/internal/cli/exec_tools.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

127 lines
4.1 KiB
Go

package cli
import (
"fmt"
"io"
"regexp"
"sort"
"strings"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/specmode"
"github.com/Gitlawb/zero/internal/tools"
)
var toolNamePattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
func parseToolList(value string) []string {
seen := map[string]bool{}
tools := []string{}
for _, name := range strings.FieldsFunc(value, func(char rune) bool {
return char == ',' || char == ' ' || char == '\t' || char == '\n' || char == '\r'
}) {
name = strings.TrimSpace(name)
if name == "" || seen[name] {
continue
}
seen[name] = true
tools = append(tools, name)
}
return tools
}
func validateExecToolFilters(options execOptions, registry *tools.Registry) error {
for _, name := range append(append([]string{}, options.enabledTools...), options.disabledTools...) {
if !toolNamePattern.MatchString(name) {
return execUsageError{fmt.Sprintf("Invalid tool name %q.", name)}
}
// tool_search is registered later (only when deferral activates), so it is
// not in the registry yet at validation time. Treat it as always-valid so an
// operator can list it in --enabled-tools / --disabled-tools without a
// spurious "Unknown tool" error.
if _, ok := registry.Get(name); !ok && name != tools.ToolSearchToolName {
return execUsageError{fmt.Sprintf("Unknown tool: %s", name)}
}
}
disabled := map[string]bool{}
for _, name := range options.disabledTools {
disabled[name] = true
}
for _, name := range options.enabledTools {
if disabled[name] {
return execUsageError{fmt.Sprintf("Tool cannot be both enabled and disabled: %s", name)}
}
}
if options.useSpec {
if disabled[specmode.SubmitToolName] {
return execUsageError{"--use-spec requires submit_spec; remove it from --disabled-tools."}
}
if len(options.enabledTools) > 0 && !toolListContains(options.enabledTools, specmode.SubmitToolName) {
return execUsageError{"--use-spec requires submit_spec; include it in --enabled-tools."}
}
}
return nil
}
func toolListContains(names []string, want string) bool {
for _, name := range names {
if name == want {
return true
}
}
return false
}
func resolveExecPermissionMode(options execOptions) (agent.PermissionMode, error) {
// Validate --auto first, regardless of --skip-permissions-unsafe, so an
// invalid autonomy value is always rejected. (Previously the unsafe path
// short-circuited before validation, letting "--auto bogus" slip through
// whenever --skip-permissions-unsafe was also set.)
var mode agent.PermissionMode
switch strings.ToLower(strings.TrimSpace(options.autonomy)) {
case "", "low", "medium":
mode = agent.PermissionModeAuto
case "high":
mode = agent.PermissionModeUnsafe
default:
return "", execUsageError{fmt.Sprintf("Invalid autonomy level %q. Expected low, medium, or high.", options.autonomy)}
}
if options.skipPermissionsUnsafe {
return agent.PermissionModeUnsafe, nil
}
return mode, nil
}
func writeExecToolList(w io.Writer, registry *tools.Registry, options execOptions, permissionMode agent.PermissionMode) error {
_, err := fmt.Fprintln(w, formatExecToolList(registry, options, permissionMode))
return err
}
func formatExecToolList(registry *tools.Registry, options execOptions, permissionMode agent.PermissionMode) string {
visible := visibleExecTools(registry, options, permissionMode)
lines := []string{"Tools visible to model:"}
for _, tool := range visible {
safety := tool.Safety()
lines = append(lines, fmt.Sprintf(" %s [%s/%s] - %s", tool.Name(), safety.SideEffect, safety.Permission, tool.Description()))
}
if len(visible) == 0 {
lines = append(lines, " (none)")
}
return strings.Join(lines, "\n")
}
func visibleExecTools(registry *tools.Registry, options execOptions, permissionMode agent.PermissionMode) []tools.Tool {
all := registry.All()
visible := []tools.Tool{}
for _, tool := range all {
if !agent.ToolVisible(tool, permissionMode, options.enabledTools, options.disabledTools) {
continue
}
visible = append(visible, tool)
}
sort.Slice(visible, func(i, j int) bool {
return visible[i].Name() < visible[j].Name()
})
return visible
}