Files
ナムandCollective Developer 84fab59226 fix(memory): guide recency recall tool calls (#1412)
* fix(memory): guide recency recall tool calls

* fix(tools): align memory and vault builtin visibility

---------

Co-authored-by: Collective Developer <man@collective.dev>
2026-07-10 01:24:39 +07:00

583 lines
18 KiB
Go

package tools
import (
"log/slog"
"slices"
"strings"
"sync"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// builtinToolGroups is const-like seed data for per-Registry tool groups.
// Do NOT modify at runtime — each Registry gets a deep copy in NewRegistry().
var builtinToolGroups = map[string][]string{
"memory": {"memory_search", "memory_get", "memory_expand", "knowledge_graph_search"},
"web": {"web_search", "web_fetch"},
"fs": {"read_file", "write_file", "list_files", "edit"},
"runtime": {"exec", "wait"},
"sessions": {"sessions_list", "sessions_history", "sessions_send", "spawn", "session_status"},
"ui": {"browser"},
"automation": {"cron"},
"messaging": {"message", "create_forum_topic", "list_group_members", "zalo_list_groups"},
"team": {"team_tasks"},
"vault": {"vault_search", "vault_read"},
// Composite group: all goclaw native tools (excludes MCP/custom plugins).
"goclaw": {
"read_file", "write_file", "list_files", "edit", "exec", "wait",
"web_search", "web_fetch", "browser",
"memory_search", "memory_get", "memory_expand",
"knowledge_graph_search", "vault_search", "vault_read",
"sessions_list", "sessions_history", "sessions_send", "spawn", "session_status",
"delegate",
"cron", "datetime", "heartbeat",
"message", "create_forum_topic", "list_group_members", "zalo_list_groups",
"read_image", "read_document", "read_audio", "read_video",
"create_image", "create_video", "create_audio",
"skill_search", "skill_manage", "publish_skill", "use_skill",
"mcp_tool_search", "tts",
"team_tasks",
},
}
// Package-level wrappers are REMOVED — use Registry methods instead.
// See Registry.RegisterToolGroup, Registry.MergeToolGroup, Registry.UnregisterToolGroup.
// Tool profiles define preset allow sets.
var toolProfiles = map[string][]string{
"minimal": {"session_status"},
"coding": {"group:fs", "group:runtime", "group:sessions", "group:memory", "group:web", "group:vault", "read_image", "create_image", "skill_search"},
"messaging": {"group:messaging", "wait", "group:web", "group:vault", "sessions_list", "sessions_history", "sessions_send", "session_status", "read_image", "skill_search"},
"full": {}, // empty = no restrictions
}
// Legacy tool aliases — migrated to Registry.RegisterAlias() at startup.
// resolveAlias() is used by IsDenied to expand names before deny-spec matching.
var legacyToolAliases = map[string]string{
"bash": "exec",
"apply-patch": "apply_patch",
"edit_file": "edit",
"sessions_spawn": "spawn",
}
// LegacyToolAliases returns legacy aliases for registration into the Registry.
func LegacyToolAliases() map[string]string {
return legacyToolAliases
}
// Subagent deny lists — tools subagents cannot use.
var subagentDenyList = []string{
"exec", // subagents should not shell out — main agent can still exec
"gateway", "agents_list", "whatsapp_login", "session_status",
"cron", "memory_search", "memory_get", "sessions_send",
}
// Leaf subagent deny — additional restrictions at max spawn depth.
var leafSubagentDenyList = []string{
"sessions_list", "sessions_history", "spawn",
}
// registryUnwrapper is implemented by ToolExecutor wrappers (e.g. userToolOverlay)
// that expose the concrete *Registry they wrap, so group-expansion machinery
// (which needs concrete *Registry, not the ToolExecutor interface) still works
// when the caller passes a request-scoped overlay instead of the raw registry.
type registryUnwrapper interface {
Unwrap() *Registry
}
// ResolveConcreteRegistry extracts the concrete *Registry backing a ToolExecutor,
// for use by group-expansion/capability/deny helpers that need direct access to
// registry-internal tool groups and metadata. Returns nil when the executor is
// neither a *Registry nor a registryUnwrapper (e.g. a test mock) — callers must
// tolerate nil (group expansion falls back to a no-op pass-through).
func ResolveConcreteRegistry(executor ToolExecutor) *Registry {
if reg, ok := executor.(*Registry); ok {
return reg
}
if unwrapper, ok := executor.(registryUnwrapper); ok {
return unwrapper.Unwrap()
}
return nil
}
// PolicyEngine evaluates tool access based on layered config policies.
type PolicyEngine struct {
globalPolicy *config.ToolsConfig
mu sync.RWMutex // protects denyCapabilities
denyCapabilities []ToolCapability // capability-based deny rules (v3)
}
// NewPolicyEngine creates a policy engine from global config.
func NewPolicyEngine(cfg *config.ToolsConfig) *PolicyEngine {
return &PolicyEngine{globalPolicy: cfg}
}
// DenyCapability adds a capability to the deny list.
// Tools with this capability are excluded from FilterTools results.
func (pe *PolicyEngine) DenyCapability(cap ToolCapability) {
pe.mu.Lock()
defer pe.mu.Unlock()
pe.denyCapabilities = append(pe.denyCapabilities, cap)
}
// FilterTools returns only the tools allowed by the policy for the given context.
// It evaluates the 7-step pipeline and returns filtered provider definitions.
func (pe *PolicyEngine) FilterTools(
registry ToolExecutor,
agentID string,
providerName string,
agentToolPolicy *config.ToolPolicySpec,
groupToolAllow []string,
isSubagent bool,
isLeafAgent bool,
) []providers.ToolDefinition {
reg := ResolveConcreteRegistry(registry)
allTools := registry.List()
allowed := pe.evaluate(reg, allTools, providerName, agentToolPolicy, groupToolAllow)
// Step 8: Capability-based deny (v3 RBAC)
pe.mu.RLock()
denyCaps := pe.denyCapabilities
pe.mu.RUnlock()
if len(denyCaps) > 0 && reg != nil {
allowed = filterByCapability(allowed, denyCaps, reg)
}
// Apply subagent restrictions
if isSubagent {
allowed = subtractSet(allowed, subagentDenyList)
}
if isLeafAgent {
allowed = subtractSet(allowed, leafSubagentDenyList)
}
// Resolve aliases and build definitions
allowedSet := make(map[string]bool, len(allowed))
var defs []providers.ToolDefinition
for _, name := range allowed {
canonical := resolveAlias(name)
if tool, ok := registry.Get(canonical); ok {
defs = append(defs, ToProviderDef(tool))
allowedSet[canonical] = true
}
}
// Add registry aliases for allowed canonical tools.
// Sort alias names for deterministic ordering (prompt caching).
aliasMap := registry.Aliases()
aliasList := make([]string, 0, len(aliasMap))
for alias := range aliasMap {
aliasList = append(aliasList, alias)
}
slices.Sort(aliasList)
for _, alias := range aliasList {
canonical := aliasMap[alias]
if !allowedSet[canonical] {
continue
}
if tool, ok := registry.Get(canonical); ok {
defs = append(defs, providers.ToolDefinition{
Type: "function",
Function: &providers.ToolFunctionSchema{
Name: alias,
Description: tool.Description(),
Parameters: tool.Parameters(),
},
})
}
}
slog.Debug("tool policy applied",
"agent", agentID,
"provider", providerName,
"total_tools", len(allTools),
"allowed", len(defs),
"is_subagent", isSubagent,
)
return defs
}
// evaluate runs the 7-step policy pipeline.
// reg is the concrete *Registry backing the caller's ToolExecutor (may be nil
// when the caller passed a mock/test executor with no group data), used for
// group-expansion ("group:xxx") and deny-spec matching. It is threaded through
// as an explicit parameter — never stored on pe — because pe is a shared,
// concurrently-used singleton; mutating a field per-call would be a data race.
func (pe *PolicyEngine) evaluate(
reg *Registry,
allTools []string,
providerName string,
agentToolPolicy *config.ToolPolicySpec,
groupToolAllow []string,
) []string {
g := pe.globalPolicy
// Step 1: Global profile
allowed := pe.applyProfile(reg, allTools, g.Profile)
// Step 2: Provider-level profile override
if g.ByProvider != nil {
if pp, ok := g.ByProvider[providerName]; ok && pp.Profile != "" {
allowed = pe.applyProfile(reg, allTools, pp.Profile)
}
}
// Step 3: Global allow list (restricts to only these)
if len(g.Allow) > 0 {
allowed = intersectWithSpec(reg, allowed, g.Allow)
}
// Step 4: Provider-level allow override
if g.ByProvider != nil {
if pp, ok := g.ByProvider[providerName]; ok && len(pp.Allow) > 0 {
allowed = intersectWithSpec(reg, allowed, pp.Allow)
}
}
// Step 5: Per-agent allow
if agentToolPolicy != nil && len(agentToolPolicy.Allow) > 0 {
allowed = intersectWithSpec(reg, allowed, agentToolPolicy.Allow)
}
// Step 6: Per-agent per-provider allow
if agentToolPolicy != nil && agentToolPolicy.ByProvider != nil {
if pp, ok := agentToolPolicy.ByProvider[providerName]; ok && len(pp.Allow) > 0 {
allowed = intersectWithSpec(reg, allowed, pp.Allow)
}
}
// Step 7: Group-level allow
if len(groupToolAllow) > 0 {
allowed = intersectWithSpec(reg, allowed, groupToolAllow)
}
// Apply global deny
if len(g.Deny) > 0 {
allowed = subtractSpec(reg, allowed, g.Deny)
}
// Apply agent deny
if agentToolPolicy != nil && len(agentToolPolicy.Deny) > 0 {
allowed = subtractSpec(reg, allowed, agentToolPolicy.Deny)
}
// Apply alsoAllow (additive — adds back tools without removing existing)
if len(g.AlsoAllow) > 0 {
allowed = unionWithSpec(reg, allowed, allTools, g.AlsoAllow)
}
if agentToolPolicy != nil && len(agentToolPolicy.AlsoAllow) > 0 {
allowed = unionWithSpec(reg, allowed, allTools, agentToolPolicy.AlsoAllow)
}
// Deny always wins: re-apply the same deny specs as a final step so that
// AlsoAllow can never reintroduce a tool (or a group containing it) that
// was explicitly denied above. Without this, unionWithSpec adds tools back
// from allTools without re-checking the deny list.
if len(g.Deny) > 0 {
allowed = subtractSpec(reg, allowed, g.Deny)
}
if agentToolPolicy != nil && len(agentToolPolicy.Deny) > 0 {
allowed = subtractSpec(reg, allowed, agentToolPolicy.Deny)
}
return allowed
}
// applyProfile returns tools allowed by a named profile.
// "full" or empty profile = all tools allowed.
func (pe *PolicyEngine) applyProfile(reg *Registry, allTools []string, profile string) []string {
if profile == "" || profile == "full" {
return copySlice(allTools)
}
spec, ok := toolProfiles[profile]
if !ok {
slog.Warn("unknown tool profile, using full", "profile", profile)
return copySlice(allTools)
}
return expandSpec(reg, allTools, spec)
}
// --- Set operations with group expansion (using per-Registry tool groups) ---
// expandSpec expands a spec list (which may contain "group:xxx") into concrete tool names,
// filtered against available tools. Uses per-Registry tool groups to avoid cross-agent races.
func expandSpec(reg *Registry, available []string, spec []string) []string {
if reg == nil {
// Fallback: no group expansion
return expandSpecNoGroups(available, spec)
}
return reg.ExpandToolGroups(available, spec)
}
// expandSpecNoGroups is a fallback when no registry is available.
func expandSpecNoGroups(available []string, spec []string) []string {
expanded := make(map[string]bool)
for _, s := range spec {
if !strings.HasPrefix(s, "group:") {
expanded[s] = true
}
}
var result []string
for _, t := range available {
if expanded[t] {
result = append(result, t)
}
}
return result
}
// intersectWithSpec keeps only tools in `current` that match the spec (with group expansion).
func intersectWithSpec(reg *Registry, current []string, spec []string) []string {
if reg == nil {
return intersectWithSpecNoGroups(current, spec)
}
reg.toolGroupsMu.RLock()
defer reg.toolGroupsMu.RUnlock()
expanded := make(map[string]bool)
for _, s := range spec {
if after, ok := strings.CutPrefix(s, "group:"); ok {
if members, ok := reg.toolGroups[after]; ok {
for _, m := range members {
expanded[m] = true
}
}
} else {
expanded[s] = true
}
}
var result []string
for _, t := range current {
if expanded[t] {
result = append(result, t)
}
}
return result
}
func intersectWithSpecNoGroups(current []string, spec []string) []string {
expanded := make(map[string]bool)
for _, s := range spec {
if !strings.HasPrefix(s, "group:") {
expanded[s] = true
}
}
var result []string
for _, t := range current {
if expanded[t] {
result = append(result, t)
}
}
return result
}
// subtractSpec removes tools matching the spec (with group expansion) from current.
func subtractSpec(reg *Registry, current []string, spec []string) []string {
if reg == nil {
return subtractSpecNoGroups(current, spec)
}
reg.toolGroupsMu.RLock()
defer reg.toolGroupsMu.RUnlock()
denied := make(map[string]bool)
for _, s := range spec {
if after, ok := strings.CutPrefix(s, "group:"); ok {
if members, ok := reg.toolGroups[after]; ok {
for _, m := range members {
denied[m] = true
}
}
} else {
denied[s] = true
}
}
var result []string
for _, t := range current {
if !denied[t] {
result = append(result, t)
}
}
return result
}
func subtractSpecNoGroups(current []string, spec []string) []string {
denied := make(map[string]bool)
for _, s := range spec {
if !strings.HasPrefix(s, "group:") {
denied[s] = true
}
}
var result []string
for _, t := range current {
if !denied[t] {
result = append(result, t)
}
}
return result
}
// subtractSet removes exact tool names from current.
func subtractSet(current []string, deny []string) []string {
denied := make(map[string]bool, len(deny))
for _, d := range deny {
denied[d] = true
}
var result []string
for _, t := range current {
if !denied[t] {
result = append(result, t)
}
}
return result
}
// unionWithSpec adds tools matching spec (from allTools) to current set.
func unionWithSpec(reg *Registry, current []string, allTools []string, spec []string) []string {
existing := make(map[string]bool, len(current))
for _, t := range current {
existing[t] = true
}
toAdd := expandSpec(reg, allTools, spec)
for _, t := range toAdd {
if !existing[t] {
current = append(current, t)
existing[t] = true
}
}
return current
}
// WouldAllow checks whether a tool name would pass the full policy pipeline
// (profile → allow → deny → alsoAllow) if it were present in the registry.
// Used to filter per-user tools (e.g. per-user MCP tools) that intentionally
// bypass registration in the shared registry to prevent credential leaks, but
// still need to respect the agent's tool policy.
//
// reg is the concrete *Registry to use for group-expansion/deny-spec matching
// (pass nil to fall back to a no-group-expansion match, e.g. from a caller that
// only has a ToolExecutor). It works by running evaluate() with just [name] as
// the available set. With a "full" profile and no allow restrictions, the name
// survives. With a restrictive allow list that excludes MCP tools, it's
// removed. With a deny containing group:mcp, MatchDenySpec removes it from the set.
func (pe *PolicyEngine) WouldAllow(reg *Registry, name, providerName string, agentPolicy *config.ToolPolicySpec, groupAllow []string) bool {
allowed := pe.evaluate(reg, []string{name}, providerName, agentPolicy, groupAllow)
for _, a := range allowed {
if a == name {
return true
}
}
return false
}
// IsDenied checks if a tool name is explicitly denied by global or agent policy.
// Used to prevent lazy-activated deferred tools from bypassing the deny list.
// Checks under all candidate names: the raw name, the legacy alias (e.g. bash→exec),
// and the registry alias when available. reg is the concrete *Registry to use for
// group-expansion/deny-spec matching (nil is tolerated — falls back to a plain match).
func (pe *PolicyEngine) IsDenied(reg *Registry, name string, agentPolicy *config.ToolPolicySpec) bool {
candidates := map[string]struct{}{name: {}}
// Keep legacy alias compatibility (e.g. bash -> exec).
candidates[resolveAlias(name)] = struct{}{}
// Include registry alias mapping when available.
if reg != nil {
if canonical, ok := reg.Aliases()[name]; ok && canonical != "" {
candidates[canonical] = struct{}{}
}
}
if pe.globalPolicy != nil {
for candidate := range candidates {
if matchDenySpec(reg, candidate, pe.globalPolicy.Deny) {
return true
}
}
}
if agentPolicy != nil {
for candidate := range candidates {
if matchDenySpec(reg, candidate, agentPolicy.Deny) {
return true
}
}
}
return false
}
// matchDenySpec returns true if name matches any entry in the deny spec (with group expansion).
func matchDenySpec(reg *Registry, name string, spec []string) bool {
if reg == nil {
// No groups to expand — plain match only
return slices.Contains(spec, name)
}
return reg.MatchDenySpec(name, spec)
}
// StripToolPrefix removes a prefix pattern from a tool name returned by the LLM.
// The template uses {tool_name} as placeholder. Example: template "proxy_{tool_name}"
// strips "proxy_" from "proxy_exec" → "exec".
// If template has no {tool_name}, it's treated as a literal prefix to strip.
func StripToolPrefix(tmpl, name string) string {
const placeholder = "{tool_name}"
if strings.Contains(tmpl, placeholder) {
parts := strings.SplitN(tmpl, placeholder, 2)
prefix, suffix := parts[0], parts[1]
// Require the name to be at least as long as prefix+suffix so an
// overlapping match (e.g. tmpl "pre_{tool_name}_suf", name "pre_suf")
// doesn't slice with a negative length and panic.
if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, suffix) &&
len(prefix)+len(suffix) <= len(name) {
result := name[len(prefix):]
if suffix != "" {
result = result[:len(result)-len(suffix)]
}
if result != "" {
return result
}
}
return name
}
// Plain prefix: strip literal prefix and any leading underscore separator
stripped := strings.TrimPrefix(name, tmpl)
if stripped == name {
return name // prefix didn't match
}
stripped = strings.TrimPrefix(stripped, "_")
if stripped == "" {
return name // nothing left after stripping
}
slog.Debug("tool_prefix.stripped", "from", name, "to", stripped, "template", tmpl)
return stripped
}
func resolveAlias(name string) string {
if canonical, ok := legacyToolAliases[name]; ok {
return canonical
}
return name
}
func copySlice(s []string) []string {
c := make([]string, len(s))
copy(c, s)
return c
}
// filterByCapability removes tools whose metadata matches any denied capability.
func filterByCapability(names []string, denyCaps []ToolCapability, reg *Registry) []string {
out := make([]string, 0, len(names))
for _, name := range names {
meta := reg.GetMetadata(name)
denied := slices.ContainsFunc(denyCaps, meta.HasCapability)
if !denied {
out = append(out, name)
}
}
return out
}