feat(agent): exclude MCP bridge tools from read-only streak detector (#400)

MCP tools (mcp_*) are user-defined external integrations where
read-heavy workflows are expected and legitimate. The read-only
streak detector was designed for filesystem tool loops but caught
MCP tools in the default fallback, triggering false positives on
workflows like "query inbox + read 10 emails + summarize."

Treat mcp_* tools as neutral (same as exec/bash) since GoClaw
cannot determine whether an MCP tool is read or write.

Closes #399
This commit is contained in:
Kai (Tam Nhu) Tran
2026-03-26 12:39:50 +07:00
committed by GitHub
parent 709d86c701
commit 6959ea7479
2 changed files with 45 additions and 3 deletions
+6 -3
View File
@@ -118,14 +118,17 @@ func (s *toolLoopState) detect(toolName string, argsHash string) (level, message
}
// recordMutation updates the read-only streak based on tool type.
// Mutating tools reset the streak; exec is neutral (ambiguous); all others increment.
// Mutating tools reset the streak; exec/bash/mcp are neutral (ambiguous); all others increment.
func (s *toolLoopState) recordMutation(toolName string) {
if mutatingTools[toolName] {
s.readOnlyStreak = 0
return
}
if toolName == "exec" || toolName == "bash" {
return // ambiguous — neither reset nor increment
// exec/bash: ambiguous (could be ls or rm).
// mcp_*: user-defined external tools — GoClaw cannot determine read vs write.
// Neither reset nor increment the read-only streak.
if toolName == "exec" || toolName == "bash" || strings.HasPrefix(toolName, "mcp_") {
return
}
s.readOnlyStreak++
}
+39
View File
@@ -171,6 +171,45 @@ func TestReadOnlyStreak_ExecNeutral(t *testing.T) {
}
}
func TestReadOnlyStreak_MCPNeutral(t *testing.T) {
var s toolLoopState
// 5 reads → streak = 5
for range 5 {
s.recordMutation("read_file")
}
// MCP tools should not reset or increment (same as exec)
s.recordMutation("mcp_gmail__query_gmail_emails")
if s.readOnlyStreak != 5 {
t.Fatalf("expected streak 5 after mcp tool, got %d", s.readOnlyStreak)
}
s.recordMutation("mcp_gmail__get_gmail_email")
if s.readOnlyStreak != 5 {
t.Fatalf("expected streak 5 after second mcp tool, got %d", s.readOnlyStreak)
}
// 7 more reads → streak = 12, should hit critical
for range 7 {
s.recordMutation("list_files")
}
if s.readOnlyStreak != 12 {
t.Fatalf("expected streak 12, got %d", s.readOnlyStreak)
}
}
func TestReadOnlyStreak_MCPOnlyNeverTriggers(t *testing.T) {
var s toolLoopState
// 20 consecutive MCP tool calls → streak should stay 0
for range 20 {
s.recordMutation("mcp_gmail__query_gmail_emails")
}
if s.readOnlyStreak != 0 {
t.Fatalf("expected streak 0 after 20 mcp-only calls, got %d", s.readOnlyStreak)
}
level, _ := s.detectReadOnlyStreak()
if level != "" {
t.Fatalf("expected no detection for mcp-only calls, got %q", level)
}
}
// --- Same-result cross-args detection ---
func TestSameResult_Warning(t *testing.T) {