mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 04:18:44 +00:00
feat(agent): tool call prefix stripping for proxy providers (#259)
* feat(agent): support tool call prefix stripping for proxy providers
Proxy providers like LiteLLM and OpenRouter may prepend a prefix to
tool call names returned by the model (e.g. "proxy_exec" instead of
"exec"). This broke tool policy validation, registry lookup, and
hardcoded name checks for "team_tasks" and "spawn" in both serial
and parallel execution paths.
Add per-agent toolCallPrefix configuration that strips the configured
prefix from incoming tool call names before registry resolution. The
stripping is applied at resolveToolCallName() which is called before
permission checks, registry execution, and spawn/team_tasks detection.
- Add StripToolPrefix() supporting literal ("proxy_") and template
("{tool_name}") patterns
- Add toolCallPrefix to ToolPolicySpec with backward compat from old
"toolPrefix" JSON key
- Fix config save using spread operator to prevent dropping new fields
- Add UI input in Tool Policy section with i18n (en/vi/zh)
* chore: ignore AI tool config directories
Add .gemini/, .claude/, .opencode/ to .gitignore to prevent
committing user-specific AI tool configurations.
This commit is contained in:
1 parent
89937edb22
commit
ffb536861f
10 files changed
+126
-26
No files matched your search
@@ -46,3 +46,6 @@ k8s-*/*
|
||||
tests/**/creds.json
|
||||
ui/simple-saas
|
||||
*.tar
|
||||
# AI tool config directories (user-specific)
|
||||
.gemini/
|
||||
.opencode/
|
||||
+32
-20
@@ -646,13 +646,14 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
}
|
||||
|
||||
// Use per-request model override if set (e.g. heartbeat uses cheaper model).
|
||||
chatMessages := messages
|
||||
model := l.model
|
||||
if req.ModelOverride != "" {
|
||||
model = req.ModelOverride
|
||||
}
|
||||
|
||||
chatReq := providers.ChatRequest{
|
||||
Messages: messages,
|
||||
Messages: chatMessages,
|
||||
Tools: toolDefs,
|
||||
Model: model,
|
||||
Options: map[string]any{
|
||||
@@ -845,7 +846,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
// Track team_tasks create for orphan detection (argument-based, pre-execution).
|
||||
// Spawn counting is done post-execution so failed spawns don't get counted.
|
||||
for _, tc := range resp.ToolCalls {
|
||||
if tc.Name == "team_tasks" {
|
||||
if l.resolveToolCallName(tc.Name) == "team_tasks" {
|
||||
if action, _ := tc.Arguments["action"].(string); action == "create" {
|
||||
teamTaskCreates++
|
||||
}
|
||||
@@ -901,29 +902,30 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
argsHash := loopDetector.record(tc.Name, tc.Arguments)
|
||||
|
||||
toolSpanStart := time.Now().UTC()
|
||||
registryName := l.resolveToolCallName(tc.Name)
|
||||
toolSpanID := l.emitToolSpanStart(ctx, toolSpanStart, tc.Name, tc.ID, string(argsJSON))
|
||||
|
||||
stopSlowTimer := toolTiming.StartSlowTimer(tc.Name, l.id, req.RunID, slowToolEnabled, emitRun)
|
||||
var result *tools.Result
|
||||
if allowedTools != nil && !allowedTools[tc.Name] {
|
||||
if allowedTools != nil && !allowedTools[registryName] {
|
||||
// Attempt lazy activation: deferred MCP tools can be activated on first call
|
||||
// so the LLM can call them by name directly without mcp_tool_search.
|
||||
if l.tools.TryActivateDeferred(tc.Name) {
|
||||
if l.tools.TryActivateDeferred(registryName) {
|
||||
// Verify tool isn't explicitly denied by policy before allowing.
|
||||
if l.toolPolicy != nil && l.toolPolicy.IsDenied(tc.Name, l.agentToolPolicy) {
|
||||
slog.Warn("security.tool_policy_denied_lazy", "agent", l.id, "tool", tc.Name)
|
||||
if l.toolPolicy != nil && l.toolPolicy.IsDenied(registryName, l.agentToolPolicy) {
|
||||
slog.Warn("security.tool_policy_denied_lazy", "agent", l.id, "tool", tc.Name, "resolved", registryName)
|
||||
result = tools.ErrorResult("tool not allowed by policy: " + tc.Name)
|
||||
} else {
|
||||
allowedTools[tc.Name] = true
|
||||
slog.Info("mcp.tool.lazy_activated", "agent", l.id, "tool", tc.Name)
|
||||
allowedTools[registryName] = true
|
||||
slog.Info("mcp.tool.lazy_activated", "agent", l.id, "tool", tc.Name, "resolved", registryName)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("security.tool_policy_blocked", "agent", l.id, "tool", tc.Name)
|
||||
slog.Warn("security.tool_policy_blocked", "agent", l.id, "tool", tc.Name, "resolved", registryName)
|
||||
result = tools.ErrorResult("tool not allowed by policy: " + tc.Name)
|
||||
}
|
||||
}
|
||||
if result == nil {
|
||||
result = l.tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, req.Channel, req.ChatID, req.PeerKind, req.SessionKey, nil)
|
||||
result = l.tools.ExecuteWithContext(ctx, registryName, tc.Arguments, req.Channel, req.ChatID, req.PeerKind, req.SessionKey, nil)
|
||||
}
|
||||
stopSlowTimer()
|
||||
|
||||
@@ -949,7 +951,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
}
|
||||
|
||||
// Count successful spawn calls for orphan detection (post-execution).
|
||||
if tc.Name == "spawn" && !result.IsError {
|
||||
if registryName == "spawn" && !result.IsError {
|
||||
if tid, _ := tc.Arguments["team_task_id"].(string); tid != "" {
|
||||
teamTaskSpawns++
|
||||
}
|
||||
@@ -1073,31 +1075,32 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
argsJSON, _ := json.Marshal(tc.Arguments)
|
||||
slog.Info("tool call", "agent", l.id, "tool", tc.Name, "args_len", len(argsJSON), "parallel", true)
|
||||
spanStart := time.Now().UTC()
|
||||
registryName := l.resolveToolCallName(tc.Name)
|
||||
// Emit running span inside goroutine — goroutine-safe (channel send only).
|
||||
// End is also emitted here to prevent orphans on ctx cancellation.
|
||||
spanID := l.emitToolSpanStart(ctx, spanStart, tc.Name, tc.ID, string(argsJSON))
|
||||
|
||||
stopSlowTimer := toolTiming.StartSlowTimer(tc.Name, l.id, req.RunID, slowToolEnabled, emitRun)
|
||||
var result *tools.Result
|
||||
if allowedTools != nil && !allowedTools[tc.Name] {
|
||||
if allowedTools != nil && !allowedTools[registryName] {
|
||||
// Attempt lazy activation for deferred MCP tools.
|
||||
// Note: don't write back to allowedTools — concurrent goroutines share
|
||||
// the map and writes would race. TryActivateDeferred is idempotent.
|
||||
if l.tools.TryActivateDeferred(tc.Name) {
|
||||
if l.tools.TryActivateDeferred(registryName) {
|
||||
// Verify tool isn't explicitly denied by policy before allowing.
|
||||
if l.toolPolicy != nil && l.toolPolicy.IsDenied(tc.Name, l.agentToolPolicy) {
|
||||
slog.Warn("security.tool_policy_denied_lazy", "agent", l.id, "tool", tc.Name)
|
||||
if l.toolPolicy != nil && l.toolPolicy.IsDenied(registryName, l.agentToolPolicy) {
|
||||
slog.Warn("security.tool_policy_denied_lazy", "agent", l.id, "tool", tc.Name, "resolved", registryName)
|
||||
result = tools.ErrorResult("tool not allowed by policy: " + tc.Name)
|
||||
} else {
|
||||
slog.Info("mcp.tool.lazy_activated", "agent", l.id, "tool", tc.Name)
|
||||
slog.Info("mcp.tool.lazy_activated", "agent", l.id, "tool", tc.Name, "resolved", registryName)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("security.tool_policy_blocked", "agent", l.id, "tool", tc.Name)
|
||||
slog.Warn("security.tool_policy_blocked", "agent", l.id, "tool", tc.Name, "resolved", registryName)
|
||||
result = tools.ErrorResult("tool not allowed by policy: " + tc.Name)
|
||||
}
|
||||
}
|
||||
if result == nil {
|
||||
result = l.tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, req.Channel, req.ChatID, req.PeerKind, req.SessionKey, nil)
|
||||
result = l.tools.ExecuteWithContext(ctx, registryName, tc.Arguments, req.Channel, req.ChatID, req.PeerKind, req.SessionKey, nil)
|
||||
}
|
||||
stopSlowTimer()
|
||||
l.emitToolSpanEnd(ctx, spanID, spanStart, result)
|
||||
@@ -1144,7 +1147,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
}
|
||||
|
||||
// Count successful spawn calls for orphan detection (post-execution).
|
||||
if r.tc.Name == "spawn" && !r.result.IsError {
|
||||
if l.resolveToolCallName(r.tc.Name) == "spawn" && !r.result.IsError {
|
||||
if tid, _ := r.tc.Arguments["team_task_id"].(string); tid != "" {
|
||||
teamTaskSpawns++
|
||||
}
|
||||
@@ -1401,7 +1404,16 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// truncateToolArgs returns a copy of arguments with string values truncated to maxLen.
|
||||
// resolveToolCallName strips the configured tool call prefix from a name
|
||||
// returned by the model, returning the original registry name.
|
||||
// Example: prefix "proxy_" + model calls "proxy_exec" → returns "exec".
|
||||
func (l *Loop) resolveToolCallName(name string) string {
|
||||
if l.agentToolPolicy != nil && l.agentToolPolicy.ToolCallPrefix != "" {
|
||||
return tools.StripToolPrefix(l.agentToolPolicy.ToolCallPrefix, name)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func truncateToolArgs(args map[string]any, maxLen int) map[string]any {
|
||||
out := make(map[string]any, len(args))
|
||||
for k, v := range args {
|
||||
|
||||
@@ -406,6 +406,7 @@ type ToolPolicySpec struct {
|
||||
Deny []string `json:"deny,omitempty"`
|
||||
AlsoAllow []string `json:"alsoAllow,omitempty"`
|
||||
ByProvider map[string]*ToolPolicySpec `json:"byProvider,omitempty"`
|
||||
ToolCallPrefix string `json:"toolCallPrefix,omitempty"` // prefix to strip from model's tool call names before registry lookup
|
||||
}
|
||||
|
||||
type WebToolsConfig struct {
|
||||
|
||||
@@ -61,6 +61,18 @@ func (a *AgentData) ParseToolsConfig() *config.ToolPolicySpec {
|
||||
if json.Unmarshal(a.ToolsConfig, &c) != nil {
|
||||
return nil
|
||||
}
|
||||
// Backward compat: migrate old "toolPrefix" key to "toolCallPrefix"
|
||||
if c.ToolCallPrefix == "" {
|
||||
var raw map[string]json.RawMessage
|
||||
if json.Unmarshal(a.ToolsConfig, &raw) == nil {
|
||||
if v, ok := raw["toolPrefix"]; ok {
|
||||
var s string
|
||||
if json.Unmarshal(v, &s) == nil && s != "" {
|
||||
c.ToolCallPrefix = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return &c
|
||||
}
|
||||
|
||||
@@ -154,6 +166,21 @@ func (a *AgentData) ParseMaxTokens() int {
|
||||
return cfg.MaxTokens
|
||||
}
|
||||
|
||||
// ParseStripAssistantPrefill extracts strip_assistant_prefill from other_config JSONB.
|
||||
// When true, trailing assistant messages are removed before sending to LLM (for proxy providers).
|
||||
func (a *AgentData) ParseStripAssistantPrefill() bool {
|
||||
if len(a.OtherConfig) == 0 {
|
||||
return false
|
||||
}
|
||||
var cfg struct {
|
||||
StripAssistantPrefill bool `json:"strip_assistant_prefill"`
|
||||
}
|
||||
if json.Unmarshal(a.OtherConfig, &cfg) != nil {
|
||||
return false
|
||||
}
|
||||
return cfg.StripAssistantPrefill
|
||||
}
|
||||
|
||||
// ParseSelfEvolve extracts self_evolve from other_config JSONB.
|
||||
// When true, predefined agents can update their SOUL.md (style/tone) through chat.
|
||||
func (a *AgentData) ParseSelfEvolve() bool {
|
||||
|
||||
@@ -398,6 +398,39 @@ func matchDenySpec(name string, spec []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// 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]
|
||||
if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, suffix) {
|
||||
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.TrimLeft(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
|
||||
|
||||
@@ -147,7 +147,9 @@
|
||||
"contextWindow": "Context Window",
|
||||
"contextWindowHint": "Token limit for the model context.",
|
||||
"maxToolIterations": "Max Tool Iterations",
|
||||
"maxToolIterationsHint": "Max tool calls per request."
|
||||
"maxToolIterationsHint": "Max tool calls per request.",
|
||||
"stripAssistantPrefill": "Strip Assistant Prefill",
|
||||
"stripAssistantPrefillHint": "Remove trailing assistant messages before LLM call (for proxy providers)"
|
||||
},
|
||||
"workspace": {
|
||||
"title": "Workspace",
|
||||
@@ -319,7 +321,9 @@
|
||||
"alsoAllow": "Also Allow",
|
||||
"selectToolsAllow": "Select tools to allow...",
|
||||
"selectToolsDeny": "Select tools to deny...",
|
||||
"selectToolsAlsoAllow": "Select additional tools..."
|
||||
"selectToolsAlsoAllow": "Select additional tools...",
|
||||
"toolCallPrefix": "Tool Call Prefix",
|
||||
"toolCallPrefixHint": "Strips this prefix from model's tool call names before registry lookup."
|
||||
},
|
||||
"workspaceSharing": {
|
||||
"title": "Workspace Sharing",
|
||||
|
||||
@@ -147,7 +147,9 @@
|
||||
"contextWindow": "Cửa sổ ngữ cảnh",
|
||||
"contextWindowHint": "Giới hạn token cho ngữ cảnh model.",
|
||||
"maxToolIterations": "Số lần lặp công cụ tối đa",
|
||||
"maxToolIterationsHint": "Số lần gọi công cụ tối đa mỗi yêu cầu."
|
||||
"maxToolIterationsHint": "Số lần gọi công cụ tối đa mỗi yêu cầu.",
|
||||
"stripAssistantPrefill": "Loại bỏ Assistant Prefill",
|
||||
"stripAssistantPrefillHint": "Loại bỏ tin nhắn assistant ở cuối trước khi gửi đến LLM (dùng cho proxy provider)"
|
||||
},
|
||||
"workspace": {
|
||||
"title": "Workspace",
|
||||
@@ -319,7 +321,9 @@
|
||||
"alsoAllow": "Cũng cho phép",
|
||||
"selectToolsAllow": "Chọn công cụ để cho phép...",
|
||||
"selectToolsDeny": "Chọn công cụ để từ chối...",
|
||||
"selectToolsAlsoAllow": "Chọn công cụ bổ sung..."
|
||||
"selectToolsAlsoAllow": "Chọn công cụ bổ sung...",
|
||||
"toolCallPrefix": "Tiền tố Tool Call",
|
||||
"toolCallPrefixHint": "Loại bỏ tiền tố này từ tên tool call của model trước khi tra cứu registry."
|
||||
},
|
||||
"workspaceSharing": {
|
||||
"title": "Chia sẻ Workspace",
|
||||
|
||||
@@ -147,7 +147,9 @@
|
||||
"contextWindow": "上下文窗口",
|
||||
"contextWindowHint": "模型上下文的令牌限制。",
|
||||
"maxToolIterations": "最大工具迭代次数",
|
||||
"maxToolIterationsHint": "每次请求最大工具调用次数。"
|
||||
"maxToolIterationsHint": "每次请求最大工具调用次数。",
|
||||
"stripAssistantPrefill": "移除 Assistant Prefill",
|
||||
"stripAssistantPrefillHint": "发送到 LLM 前移除末尾的 assistant 消息(适用于代理提供商)"
|
||||
},
|
||||
"workspace": {
|
||||
"title": "工作区",
|
||||
@@ -319,7 +321,9 @@
|
||||
"alsoAllow": "同时允许",
|
||||
"selectToolsAllow": "选择要允许的工具...",
|
||||
"selectToolsDeny": "选择要拒绝的工具...",
|
||||
"selectToolsAlsoAllow": "选择附加工具..."
|
||||
"selectToolsAlsoAllow": "选择附加工具...",
|
||||
"toolCallPrefix": "工具调用前缀",
|
||||
"toolCallPrefixHint": "从模型的工具调用名称中去除此前缀后再查找注册表。"
|
||||
},
|
||||
"workspaceSharing": {
|
||||
"title": "工作区共享",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { ToolPolicyConfig } from "@/types/agent";
|
||||
import { ConfigSection, InfoLabel } from "./config-section";
|
||||
import { ToolNameSelect } from "@/components/shared/tool-name-select";
|
||||
@@ -42,6 +43,16 @@ export function ToolPolicySection({ enabled, value, onToggle, onChange }: ToolPo
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Strip prefix from tool call names returned by the model before registry lookup. Example: 'proxy_' strips prefix so 'proxy_exec' resolves to 'exec'. Supports {tool_name} placeholder.">{t(`${s}.toolCallPrefix`)}</InfoLabel>
|
||||
<Input
|
||||
value={value.toolCallPrefix ?? ""}
|
||||
onChange={(e) => onChange({ ...value, toolCallPrefix: e.target.value.replace(/[^a-z0-9_{}/]/g, "") || undefined })}
|
||||
placeholder="e.g. proxy_"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t(`${s}.toolCallPrefixHint`)}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Explicit allowlist. Only these tools will be available (overrides profile). Leave empty to use profile defaults.">{t(`${s}.allow`)}</InfoLabel>
|
||||
<ToolNameSelect
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface ToolPolicyConfig {
|
||||
deny?: string[];
|
||||
alsoAllow?: string[];
|
||||
byProvider?: Record<string, { profile?: string; allow?: string[]; deny?: string[]; alsoAllow?: string[] }>;
|
||||
toolCallPrefix?: string; // prefix to strip from model's tool call names
|
||||
}
|
||||
|
||||
export interface SubagentsConfig {
|
||||
|
||||
Reference in new issue
Block a user