mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -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).
127 lines
4.1 KiB
Go
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
|
|
}
|