feat(agent): tenant tool settings overlay via loop ctx injection

Plumb per-tenant tool settings into the agent Loop without touching any
tool's Execute signature. Adds WithTenantToolSettings ctx helper and
rewrites BuiltinToolSettingsFromCtx with fast-path merge semantics —
tenant layer wins over global defaults at tool-name level (no field-level
deep merge). Resolver preloads ListAllSettings for the agent's tenant at
Loop construction; store errors log + fall back to global. Zero allocs
on single-tier reads. Tier 1 (future per-agent override) is reserved and
documented in context_keys.go. 8 unit tests cover empty / single-tier /
both-merged / RunContext fallback / fast-path semantics.
This commit is contained in:
viettranx
2026-04-12 08:55:20 +07:00
parent 933c2e10d9
commit 56eb686934
5 changed files with 256 additions and 11 deletions
+7 -1
View File
@@ -67,10 +67,16 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
if req.SenderName != "" {
ctx = store.WithSenderName(ctx, req.SenderName)
}
// Inject global builtin tool settings for media tools (provider chain)
// Inject global + per-agent builtin tool settings (tier 1+3).
// Media/provider-chain tools read the merged view via BuiltinToolSettingsFromCtx.
if l.builtinToolSettings != nil {
ctx = tools.WithBuiltinToolSettings(ctx, l.builtinToolSettings)
}
// Inject tenant-layer tool settings (tier 2). Merge with per-agent happens
// at read time — per-agent still wins at tool-name level.
if l.tenantToolSettings != nil {
ctx = tools.WithTenantToolSettings(ctx, l.tenantToolSettings)
}
// Inject channel type into context for tools (e.g. message tool needs it for Zalo group routing)
if req.ChannelType != "" {
ctx = tools.WithToolChannelType(ctx, req.ChannelType)
+12 -2
View File
@@ -160,9 +160,15 @@ type Loop struct {
injectionAction string // "log", "warn" (default), "block", "off"
maxMessageChars int // 0 = use default (32000)
// Global builtin tool settings (from builtin_tools table)
// Global builtin tool settings (from builtin_tools.settings table).
// Tier 3 in the overlay — tenant (tier 2) and future per-agent (tier 1) sit above.
builtinToolSettings tools.BuiltinToolSettings
// Tenant-layer tool settings overlay (from builtin_tool_tenant_configs.settings).
// Tier 2 — sits above global (tier 3) and is merged at read time in
// BuiltinToolSettingsFromCtx with global winning at tool-name level.
tenantToolSettings tools.BuiltinToolSettings
// Per-tenant disabled tools (tool name → true means excluded from LLM)
disabledTools map[string]bool
@@ -327,9 +333,12 @@ type LoopConfig struct {
InjectionAction string // "log", "warn" (default), "block", "off"
MaxMessageChars int // 0 = use default (32000)
// Global builtin tool settings (from builtin_tools table)
// Global builtin tool settings (from builtin_tools table, merged with per-agent overrides)
BuiltinToolSettings tools.BuiltinToolSettings
// Tenant-layer tool settings overlay (from builtin_tool_tenant_configs.settings).
TenantToolSettings tools.BuiltinToolSettings
// Per-tenant disabled tools (tool name → true means excluded)
DisabledTools map[string]bool
@@ -474,6 +483,7 @@ func NewLoop(cfg LoopConfig) *Loop {
injectionAction: action,
maxMessageChars: cfg.MaxMessageChars,
builtinToolSettings: cfg.BuiltinToolSettings,
tenantToolSettings: cfg.TenantToolSettings,
disabledTools: cfg.DisabledTools,
reasoningConfig: cfg.ReasoningConfig,
promptMode: cfg.PromptMode,
+16 -1
View File
@@ -343,7 +343,11 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
}
// Load per-tenant tool exclusions (disabled tools for this agent's tenant)
var disabledTools map[string]bool
// AND per-tenant tool settings overlay (tier 2 in the 4-tier cascade).
var (
disabledTools map[string]bool
tenantToolSettings tools.BuiltinToolSettings
)
if deps.BuiltinToolTenantCfgs != nil && ag.TenantID != uuid.Nil {
if disabled, err := deps.BuiltinToolTenantCfgs.ListDisabled(ctx, ag.TenantID); err == nil && len(disabled) > 0 {
disabledTools = make(map[string]bool, len(disabled))
@@ -352,6 +356,16 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
}
slog.Debug("tenant tool exclusions", "agent", agentKey, "tenant", ag.TenantID, "disabled", len(disabled))
}
if settings, err := deps.BuiltinToolTenantCfgs.ListAllSettings(ctx, ag.TenantID); err != nil {
// Log but don't fail agent creation — fall back to global/hardcoded defaults.
slog.Warn("failed to load tenant tool settings", "agent", agentKey, "tenant", ag.TenantID, "error", err)
} else if len(settings) > 0 {
tenantToolSettings = make(tools.BuiltinToolSettings, len(settings))
for name, raw := range settings {
tenantToolSettings[name] = []byte(raw)
}
slog.Debug("tenant tool settings loaded", "agent", agentKey, "tenant", ag.TenantID, "tools", len(tenantToolSettings))
}
}
// Filter skills by visibility + agent grants.
@@ -452,6 +466,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
SandboxContainerDir: sandboxContainerDir,
SandboxWorkspaceAccess: sandboxWorkspaceAccess,
BuiltinToolSettings: builtinSettings,
TenantToolSettings: tenantToolSettings,
DisabledTools: disabledTools,
ReasoningConfig: store.ResolveEffectiveReasoningConfig(providerReasoningDefaults, ag.ParseReasoningConfig()),
PromptMode: PromptMode(ag.ParsePromptMode()),
@@ -0,0 +1,145 @@
package tools
import (
"bytes"
"context"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// ---- BuiltinToolSettingsFromCtx merge semantics (4-tier overlay) ----
func TestBuiltinToolSettingsFromCtx_EmptyCtx_ReturnsNil(t *testing.T) {
if got := BuiltinToolSettingsFromCtx(context.Background()); got != nil {
t.Errorf("empty ctx: want nil, got %v", got)
}
}
func TestBuiltinToolSettingsFromCtx_PerAgentOnly(t *testing.T) {
perAgent := BuiltinToolSettings{"web_search": []byte(`{"k":"per-agent"}`)}
ctx := WithBuiltinToolSettings(context.Background(), perAgent)
got := BuiltinToolSettingsFromCtx(ctx)
if !bytes.Equal(got["web_search"], []byte(`{"k":"per-agent"}`)) {
t.Errorf("per-agent only: got %s", got["web_search"])
}
// Fast path: same map returned (no copy).
if &got == &perAgent {
// maps are reference types — compare underlying via len + value
}
if len(got) != 1 {
t.Errorf("expected single entry, got %d", len(got))
}
}
func TestBuiltinToolSettingsFromCtx_TenantOnly(t *testing.T) {
tenant := BuiltinToolSettings{"web_search": []byte(`{"k":"tenant"}`)}
ctx := WithTenantToolSettings(context.Background(), tenant)
got := BuiltinToolSettingsFromCtx(ctx)
if !bytes.Equal(got["web_search"], []byte(`{"k":"tenant"}`)) {
t.Errorf("tenant only: got %s", got["web_search"])
}
}
func TestBuiltinToolSettingsFromCtx_BothMergeTenantWinsOverGlobal(t *testing.T) {
// WithBuiltinToolSettings carries tier 3 (global defaults).
// WithTenantToolSettings carries tier 2 (tenant admin override).
// Tenant must win at tool-name level.
global := BuiltinToolSettings{
"web_search": []byte(`{"k":"global"}`),
"web_fetch": []byte(`{"k":"global"}`),
}
tenant := BuiltinToolSettings{
"web_search": []byte(`{"k":"tenant"}`), // overrides global
"tts": []byte(`{"k":"tenant"}`), // tenant-only
}
ctx := WithBuiltinToolSettings(context.Background(), global)
ctx = WithTenantToolSettings(ctx, tenant)
got := BuiltinToolSettingsFromCtx(ctx)
if len(got) != 3 {
t.Errorf("merged: want 3 entries, got %d (%v)", len(got), got)
}
// web_search: tenant wins (overrides global default).
if !bytes.Equal(got["web_search"], []byte(`{"k":"tenant"}`)) {
t.Errorf("tenant should override global for web_search, got %s", got["web_search"])
}
// web_fetch: only global has it — global value surfaces.
if !bytes.Equal(got["web_fetch"], []byte(`{"k":"global"}`)) {
t.Errorf("web_fetch should come from global, got %s", got["web_fetch"])
}
// tts: only tenant has it.
if !bytes.Equal(got["tts"], []byte(`{"k":"tenant"}`)) {
t.Errorf("tts should come from tenant, got %s", got["tts"])
}
}
func TestBuiltinToolSettingsFromCtx_RunContextFallback(t *testing.T) {
// Neither ctx key set but RunContext has settings — should fall back.
rc := &store.RunContext{
BuiltinToolSettings: map[string][]byte{"web_search": []byte(`{"from":"rc"}`)},
}
ctx := store.WithRunContext(context.Background(), rc)
got := BuiltinToolSettingsFromCtx(ctx)
if !bytes.Equal(got["web_search"], []byte(`{"from":"rc"}`)) {
t.Errorf("RunContext fallback: got %s", got["web_search"])
}
}
func TestBuiltinToolSettingsFromCtx_BuiltinCtxKeyOverridesRunContext(t *testing.T) {
// Builtin ctx key must take precedence over RunContext fallback.
// RunContext only serves empty-both-keys case.
rc := &store.RunContext{
BuiltinToolSettings: map[string][]byte{"web_search": []byte(`{"from":"rc"}`)},
}
global := BuiltinToolSettings{"web_search": []byte(`{"from":"global"}`)}
ctx := store.WithRunContext(context.Background(), rc)
ctx = WithBuiltinToolSettings(ctx, global)
got := BuiltinToolSettingsFromCtx(ctx)
if !bytes.Equal(got["web_search"], []byte(`{"from":"global"}`)) {
t.Errorf("ctx key must override RunContext, got %s", got["web_search"])
}
}
// ---- TenantToolSettingsFromCtx raw accessor ----
func TestTenantToolSettingsFromCtx_RoundTrip(t *testing.T) {
tenant := BuiltinToolSettings{"web_search": []byte(`{"k":"tenant"}`)}
ctx := WithTenantToolSettings(context.Background(), tenant)
got := TenantToolSettingsFromCtx(ctx)
if len(got) != 1 || !bytes.Equal(got["web_search"], tenant["web_search"]) {
t.Errorf("raw tenant round-trip failed: %v", got)
}
// Empty ctx → nil
if TenantToolSettingsFromCtx(context.Background()) != nil {
t.Errorf("empty ctx should return nil tenant settings")
}
}
// ---- Fast path allocation check ----
// When only one tier is present, the merge function must return the same
// underlying map without allocating. We verify by checking equality after
// mutation — if a copy was made, the original wouldn't see the change.
//
// Note: this is a behavioral assertion, not an allocation benchmark.
func TestBuiltinToolSettingsFromCtx_FastPath_ReturnsSameMap(t *testing.T) {
perAgent := BuiltinToolSettings{"web_search": []byte(`original`)}
ctx := WithBuiltinToolSettings(context.Background(), perAgent)
got := BuiltinToolSettingsFromCtx(ctx)
// Mutate the returned map — if it's the same map, perAgent sees the change.
got["web_fetch"] = []byte(`mutated`)
if _, ok := perAgent["web_fetch"]; !ok {
t.Errorf("fast path should return same map, got a copy")
}
}
+76 -7
View File
@@ -174,25 +174,94 @@ func RunKindFromCtx(ctx context.Context) string {
// Leader agents in this mode can only relay status — mutations are blocked.
const RunKindNotification = "notification"
// --- Builtin tool settings (global DB overrides) ---
// --- Builtin tool settings (3-tier overlay, tier-1 reserved) ---
//
// Tool config resolution order (most specific wins):
// 1. (reserved — future per-agent override)
// 2. Tenant override (builtin_tool_tenant_configs.settings, via WithTenantToolSettings)
// 3. Global default (builtin_tools.settings, via WithBuiltinToolSettings — resolver-loaded)
// 4. Hardcoded (tool internal default when the map has no entry)
//
// `WithBuiltinToolSettings` carries the resolver-loaded global defaults (tier 3).
// If per-agent overrides are added later, they can share this ctx key (same map) or
// introduce a dedicated key — the merge function's precedence (builtin ctx key wins
// over tenant) already reflects "more specific wins".
//
// Merge happens at tool-name level: if both tiers define `web_search`, the builtin
// ctx-key value wins wholesale (no field-level deep merge inside the JSON blob).
const ctxBuiltinToolSettings toolContextKey = "tool_builtin_settings"
const (
ctxBuiltinToolSettings toolContextKey = "tool_builtin_settings"
ctxTenantToolSettings toolContextKey = "tool_tenant_settings"
)
// BuiltinToolSettings maps tool name → settings JSON bytes.
type BuiltinToolSettings map[string][]byte
// WithBuiltinToolSettings injects the per-agent + global tool settings map.
// Tier 1 + tier 3 in the overlay (coalesced by the resolver).
func WithBuiltinToolSettings(ctx context.Context, settings BuiltinToolSettings) context.Context {
return context.WithValue(ctx, ctxBuiltinToolSettings, settings)
}
// WithTenantToolSettings injects the tenant-layer tool settings map (tier 2).
// Loaded by the resolver via BuiltinToolTenantConfigStore.ListAllSettings.
func WithTenantToolSettings(ctx context.Context, settings BuiltinToolSettings) context.Context {
return context.WithValue(ctx, ctxTenantToolSettings, settings)
}
// TenantToolSettingsFromCtx returns the raw tenant-layer map without merging.
// Use BuiltinToolSettingsFromCtx for the merged view that tools should read.
func TenantToolSettingsFromCtx(ctx context.Context) BuiltinToolSettings {
v, _ := ctx.Value(ctxTenantToolSettings).(BuiltinToolSettings)
return v
}
// BuiltinToolSettingsFromCtx returns the merged tool settings view.
//
// Merge semantics (current wiring — tier 1 per-agent override not yet loaded):
// - `WithBuiltinToolSettings` carries tier 3 (global defaults)
// - `WithTenantToolSettings` carries tier 2 (tenant admin override)
// - Tenant wins over global at tool-name level (no field-level deep merge)
//
// When a future phase adds tier 1 (per-agent override), it will either
// introduce a third ctx key that outranks both, OR the resolver will merge
// tier 1 into the builtin-ctx-key map before injection. In the latter case,
// semantics remain correct so long as tier 1 is layered ABOVE tenant — the
// resolver will need a split then.
//
// Fast paths: single-tier or empty merge returns the underlying map directly
// with zero allocations. Merge only runs when BOTH layers have entries.
func BuiltinToolSettingsFromCtx(ctx context.Context) BuiltinToolSettings {
if v, _ := ctx.Value(ctxBuiltinToolSettings).(BuiltinToolSettings); v != nil {
return v
global, _ := ctx.Value(ctxBuiltinToolSettings).(BuiltinToolSettings)
tenant, _ := ctx.Value(ctxTenantToolSettings).(BuiltinToolSettings)
if len(global) == 0 && len(tenant) == 0 {
// RunContext fallback (existing behavior — subagent runs inherit
// parent's BuiltinToolSettings through RunContext serialization).
if rc := store.RunContextFromCtx(ctx); rc != nil && rc.BuiltinToolSettings != nil {
return BuiltinToolSettings(rc.BuiltinToolSettings)
}
return nil
}
if rc := store.RunContextFromCtx(ctx); rc != nil {
return BuiltinToolSettings(rc.BuiltinToolSettings)
// Fast paths — no allocation when only one tier is active.
if len(tenant) == 0 {
return global
}
return nil
if len(global) == 0 {
return tenant
}
// Both tiers present: layer tenant override on top of global defaults.
merged := make(BuiltinToolSettings, len(global)+len(tenant))
for k, v := range global {
merged[k] = v
}
for k, v := range tenant {
merged[k] = v
}
return merged
}
// --- Per-agent restrict_to_workspace override ---