Skip to content

Commit 6e94c5f

Browse files
committed
fix(agent): review fixes for tool call prefix stripping (#259)
- Fix TrimLeft → TrimPrefix to strip only one underscore separator - Add registryName to indexedResult for parallel path cache - Use registryName for bootstrapToolAllowlist and loopDetector checks - Add server-side sanitization for ToolCallPrefix input - Remove dead chatMessages alias and unrelated ParseStripAssistantPrefill - Remove orphaned stripAssistantPrefill i18n keys - Add 13 unit tests for StripToolPrefix
1 parent ffb5368 commit 6e94c5f

7 files changed

Lines changed: 88 additions & 53 deletions

File tree

internal/agent/loop.go

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -646,14 +646,13 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
646646
}
647647

648648
// Use per-request model override if set (e.g. heartbeat uses cheaper model).
649-
chatMessages := messages
650649
model := l.model
651650
if req.ModelOverride != "" {
652651
model = req.ModelOverride
653652
}
654653

655654
chatReq := providers.ChatRequest{
656-
Messages: chatMessages,
655+
Messages: messages,
657656
Tools: toolDefs,
658657
Model: model,
659658
Options: map[string]any{
@@ -899,10 +898,10 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
899898
argsJSON, _ := json.Marshal(tc.Arguments)
900899
slog.Info("tool call", "agent", l.id, "tool", tc.Name, "args_len", len(argsJSON))
901900

902-
argsHash := loopDetector.record(tc.Name, tc.Arguments)
901+
registryName := l.resolveToolCallName(tc.Name)
902+
argsHash := loopDetector.record(registryName, tc.Arguments)
903903

904904
toolSpanStart := time.Now().UTC()
905-
registryName := l.resolveToolCallName(tc.Name)
906905
toolSpanID := l.emitToolSpanStart(ctx, toolSpanStart, tc.Name, tc.ID, string(argsJSON))
907906

908907
stopSlowTimer := toolTiming.StartSlowTimer(tc.Name, l.id, req.RunID, slowToolEnabled, emitRun)
@@ -936,7 +935,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
936935

937936
// Record result for loop detection.
938937
loopDetector.recordResult(argsHash, result.ForLLM)
939-
loopDetector.recordMutation(tc.Name)
938+
loopDetector.recordMutation(registryName)
940939

941940
if result.Async {
942941
asyncToolCalls = append(asyncToolCalls, tc.Name)
@@ -956,7 +955,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
956955
teamTaskSpawns++
957956
}
958957
}
959-
if hadBootstrap && bootstrapToolAllowlist[tc.Name] {
958+
if hadBootstrap && bootstrapToolAllowlist[registryName] {
960959
bootstrapWriteDetected = true
961960
}
962961

@@ -1006,14 +1005,14 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
10061005
pendingMsgs = append(pendingMsgs, toolMsg)
10071006

10081007
// Check for tool call loop after recording result.
1009-
if level, msg := loopDetector.detect(tc.Name, argsHash); level != "" {
1008+
if level, msg := loopDetector.detect(registryName, argsHash); level != "" {
10101009
if level == "critical" {
1011-
slog.Warn("tool loop critical", "agent", l.id, "tool", tc.Name, "message", msg)
1012-
finalContent = "I was unable to complete this task — I got stuck repeatedly calling " + tc.Name + " without making progress. Please try rephrasing your request."
1010+
slog.Warn("tool loop critical", "agent", l.id, "tool", registryName, "message", msg)
1011+
finalContent = "I was unable to complete this task — I got stuck repeatedly calling " + registryName + " without making progress. Please try rephrasing your request."
10131012
break
10141013
}
10151014
// Warning: inject message so model knows to change strategy.
1016-
slog.Warn("tool loop warning", "agent", l.id, "tool", tc.Name, "message", msg)
1015+
slog.Warn("tool loop warning", "agent", l.id, "tool", registryName, "message", msg)
10171016
messages = append(messages, providers.Message{Role: "user", Content: msg})
10181017
}
10191018

@@ -1032,10 +1031,10 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
10321031

10331032
// Check for same tool returning identical results with different args.
10341033
if rh := hashResult(result.ForLLM); rh != "" {
1035-
if level, msg := loopDetector.detectSameResult(tc.Name, rh); level != "" {
1034+
if level, msg := loopDetector.detectSameResult(registryName, rh); level != "" {
10361035
if level == "critical" {
10371036
slog.Warn("tool loop critical: same result",
1038-
"tool", tc.Name, "agent", l.id, "run", req.RunID)
1037+
"tool", registryName, "agent", l.id, "run", req.RunID)
10391038
finalContent = msg
10401039
break
10411040
}
@@ -1047,11 +1046,12 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
10471046
// Tool instances are immutable (context-based) so concurrent access is safe.
10481047
// Results are collected then processed sequentially for deterministic ordering.
10491048
type indexedResult struct {
1050-
idx int
1051-
tc providers.ToolCall
1052-
result *tools.Result
1053-
argsJSON string
1054-
spanStart time.Time
1049+
idx int
1050+
tc providers.ToolCall
1051+
registryName string
1052+
result *tools.Result
1053+
argsJSON string
1054+
spanStart time.Time
10551055
}
10561056

10571057
// 1. Emit all tool.call events upfront (client sees all calls starting)
@@ -1104,7 +1104,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
11041104
}
11051105
stopSlowTimer()
11061106
l.emitToolSpanEnd(ctx, spanID, spanStart, result)
1107-
resultCh <- indexedResult{idx: idx, tc: tc, result: result, argsJSON: string(argsJSON), spanStart: spanStart}
1107+
resultCh <- indexedResult{idx: idx, tc: tc, registryName: registryName, result: result, argsJSON: string(argsJSON), spanStart: spanStart}
11081108
}(i, tc)
11091109
}
11101110

@@ -1130,9 +1130,9 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
11301130
toolTiming.Record(r.tc.Name, time.Since(r.spanStart).Milliseconds())
11311131

11321132
// Record for loop detection.
1133-
argsHash := loopDetector.record(r.tc.Name, r.tc.Arguments)
1133+
argsHash := loopDetector.record(r.registryName, r.tc.Arguments)
11341134
loopDetector.recordResult(argsHash, r.result.ForLLM)
1135-
loopDetector.recordMutation(r.tc.Name)
1135+
loopDetector.recordMutation(r.registryName)
11361136

11371137
if r.result.Async {
11381138
asyncToolCalls = append(asyncToolCalls, r.tc.Name)
@@ -1147,12 +1147,12 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
11471147
}
11481148

11491149
// Count successful spawn calls for orphan detection (post-execution).
1150-
if l.resolveToolCallName(r.tc.Name) == "spawn" && !r.result.IsError {
1150+
if r.registryName == "spawn" && !r.result.IsError {
11511151
if tid, _ := r.tc.Arguments["team_task_id"].(string); tid != "" {
11521152
teamTaskSpawns++
11531153
}
11541154
}
1155-
if hadBootstrap && bootstrapToolAllowlist[r.tc.Name] {
1155+
if hadBootstrap && bootstrapToolAllowlist[r.registryName] {
11561156
bootstrapWriteDetected = true
11571157
}
11581158

@@ -1202,23 +1202,23 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
12021202
pendingMsgs = append(pendingMsgs, toolMsg)
12031203

12041204
// Check for tool call loop.
1205-
if level, msg := loopDetector.detect(r.tc.Name, argsHash); level != "" {
1205+
if level, msg := loopDetector.detect(r.registryName, argsHash); level != "" {
12061206
if level == "critical" {
1207-
slog.Warn("tool loop critical", "agent", l.id, "tool", r.tc.Name, "message", msg)
1208-
finalContent = "I was unable to complete this task — I got stuck repeatedly calling " + r.tc.Name + " without making progress. Please try rephrasing your request."
1207+
slog.Warn("tool loop critical", "agent", l.id, "tool", r.registryName, "message", msg)
1208+
finalContent = "I was unable to complete this task — I got stuck repeatedly calling " + r.registryName + " without making progress. Please try rephrasing your request."
12091209
loopStuck = true
12101210
break
12111211
}
1212-
slog.Warn("tool loop warning", "agent", l.id, "tool", r.tc.Name, "message", msg)
1212+
slog.Warn("tool loop warning", "agent", l.id, "tool", r.registryName, "message", msg)
12131213
messages = append(messages, providers.Message{Role: "user", Content: msg})
12141214
}
12151215

12161216
// Check for same tool returning identical results with different args.
12171217
if rh := hashResult(r.result.ForLLM); rh != "" {
1218-
if level, msg := loopDetector.detectSameResult(r.tc.Name, rh); level != "" {
1218+
if level, msg := loopDetector.detectSameResult(r.registryName, rh); level != "" {
12191219
if level == "critical" {
12201220
slog.Warn("tool loop critical: same result",
1221-
"tool", r.tc.Name, "agent", l.id, "run", req.RunID)
1221+
"tool", r.registryName, "agent", l.id, "run", req.RunID)
12221222
finalContent = msg
12231223
loopStuck = true
12241224
break

internal/store/agent_store.go

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,27 @@ package store
33
import (
44
"context"
55
"encoding/json"
6+
"strings"
67

78
"github.com/google/uuid"
89
"github.com/nextlevelbuilder/goclaw/internal/config"
910
)
1011

12+
// sanitizeToolCallPrefix strips characters not in [a-z0-9_{}] from the prefix.
13+
// This matches the UI-side regex and prevents injection via direct API calls.
14+
func sanitizeToolCallPrefix(s string) string {
15+
if s == "" {
16+
return ""
17+
}
18+
var b strings.Builder
19+
for _, r := range s {
20+
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '{' || r == '}' {
21+
b.WriteRune(r)
22+
}
23+
}
24+
return b.String()
25+
}
26+
1127
// Agent type constants.
1228
const (
1329
AgentTypeOpen = "open" // per-user context files, seeded on first chat
@@ -73,6 +89,8 @@ func (a *AgentData) ParseToolsConfig() *config.ToolPolicySpec {
7389
}
7490
}
7591
}
92+
// Sanitize: only allow [a-z0-9_{}] to prevent injection via API bypass.
93+
c.ToolCallPrefix = sanitizeToolCallPrefix(c.ToolCallPrefix)
7694
return &c
7795
}
7896

@@ -166,21 +184,6 @@ func (a *AgentData) ParseMaxTokens() int {
166184
return cfg.MaxTokens
167185
}
168186

169-
// ParseStripAssistantPrefill extracts strip_assistant_prefill from other_config JSONB.
170-
// When true, trailing assistant messages are removed before sending to LLM (for proxy providers).
171-
func (a *AgentData) ParseStripAssistantPrefill() bool {
172-
if len(a.OtherConfig) == 0 {
173-
return false
174-
}
175-
var cfg struct {
176-
StripAssistantPrefill bool `json:"strip_assistant_prefill"`
177-
}
178-
if json.Unmarshal(a.OtherConfig, &cfg) != nil {
179-
return false
180-
}
181-
return cfg.StripAssistantPrefill
182-
}
183-
184187
// ParseSelfEvolve extracts self_evolve from other_config JSONB.
185188
// When true, predefined agents can update their SOUL.md (style/tone) through chat.
186189
func (a *AgentData) ParseSelfEvolve() bool {

internal/tools/policy.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,7 @@ func StripToolPrefix(tmpl, name string) string {
423423
if stripped == name {
424424
return name // prefix didn't match
425425
}
426-
stripped = strings.TrimLeft(stripped, "_")
426+
stripped = strings.TrimPrefix(stripped, "_")
427427
if stripped == "" {
428428
return name // nothing left after stripping
429429
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package tools
2+
3+
import "testing"
4+
5+
func TestStripToolPrefix(t *testing.T) {
6+
tests := []struct {
7+
name string
8+
tmpl string
9+
input string
10+
expect string
11+
}{
12+
// Template-based ({tool_name} placeholder)
13+
{"template match", "proxy_{tool_name}", "proxy_exec", "exec"},
14+
{"template with suffix", "{tool_name}_v2", "exec_v2", "exec"},
15+
{"template prefix+suffix", "pre_{tool_name}_suf", "pre_exec_suf", "exec"},
16+
{"template no match", "proxy_{tool_name}", "other_exec", "other_exec"},
17+
{"template empty result", "proxy_{tool_name}", "proxy_", "proxy_"},
18+
{"template exact placeholder", "{tool_name}", "exec", "exec"},
19+
20+
// Literal prefix
21+
{"literal prefix with separator", "proxy_", "proxy_exec", "exec"},
22+
{"literal prefix no match", "proxy_", "other_exec", "other_exec"},
23+
{"literal prefix underscore join", "proxy", "proxy_exec", "exec"},
24+
{"literal prefix single underscore strip", "proxy", "proxy__exec", "_exec"},
25+
{"literal prefix empty after strip", "proxy_", "proxy_", "proxy_"},
26+
{"literal name shorter than prefix", "proxy_", "pro", "pro"},
27+
{"literal name equals prefix", "proxy", "proxy", "proxy"},
28+
}
29+
30+
for _, tt := range tests {
31+
t.Run(tt.name, func(t *testing.T) {
32+
got := StripToolPrefix(tt.tmpl, tt.input)
33+
if got != tt.expect {
34+
t.Errorf("StripToolPrefix(%q, %q) = %q, want %q", tt.tmpl, tt.input, got, tt.expect)
35+
}
36+
})
37+
}
38+
}

ui/web/src/i18n/locales/en/agents.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,7 @@
147147
"contextWindow": "Context Window",
148148
"contextWindowHint": "Token limit for the model context.",
149149
"maxToolIterations": "Max Tool Iterations",
150-
"maxToolIterationsHint": "Max tool calls per request.",
151-
"stripAssistantPrefill": "Strip Assistant Prefill",
152-
"stripAssistantPrefillHint": "Remove trailing assistant messages before LLM call (for proxy providers)"
150+
"maxToolIterationsHint": "Max tool calls per request."
153151
},
154152
"workspace": {
155153
"title": "Workspace",

ui/web/src/i18n/locales/vi/agents.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,7 @@
147147
"contextWindow": "Cửa sổ ngữ cảnh",
148148
"contextWindowHint": "Giới hạn token cho ngữ cảnh model.",
149149
"maxToolIterations": "Số lần lặp công cụ tối đa",
150-
"maxToolIterationsHint": "Số lần gọi công cụ tối đa mỗi yêu cầu.",
151-
"stripAssistantPrefill": "Loại bỏ Assistant Prefill",
152-
"stripAssistantPrefillHint": "Loại bỏ tin nhắn assistant ở cuối trước khi gửi đến LLM (dùng cho proxy provider)"
150+
"maxToolIterationsHint": "Số lần gọi công cụ tối đa mỗi yêu cầu."
153151
},
154152
"workspace": {
155153
"title": "Workspace",

ui/web/src/i18n/locales/zh/agents.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,7 @@
147147
"contextWindow": "上下文窗口",
148148
"contextWindowHint": "模型上下文的令牌限制。",
149149
"maxToolIterations": "最大工具迭代次数",
150-
"maxToolIterationsHint": "每次请求最大工具调用次数。",
151-
"stripAssistantPrefill": "移除 Assistant Prefill",
152-
"stripAssistantPrefillHint": "发送到 LLM 前移除末尾的 assistant 消息(适用于代理提供商)"
150+
"maxToolIterationsHint": "每次请求最大工具调用次数。"
153151
},
154152
"workspace": {
155153
"title": "工作区",

0 commit comments

Comments
 (0)