mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-08-22 04:23:45 +00:00
310 lines
11 KiB
Go
310 lines
11 KiB
Go
package agent
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Tool loop detection thresholds (per-run, not per-session).
|
|
const (
|
|
toolLoopHistorySize = 30
|
|
toolLoopWarningThreshold = 3 // inject warning into conversation
|
|
toolLoopCriticalThreshold = 5 // force stop the iteration loop
|
|
|
|
// Read-only streak: consecutive non-mutating tool calls without any write/edit.
|
|
// Stuck mode (uniqueness ratio ≤ 0.6): original thresholds.
|
|
readOnlyStreakWarning = 8
|
|
readOnlyStreakCritical = 12
|
|
|
|
// Exploration mode (uniqueness ratio > 0.6): relaxed thresholds.
|
|
// Agents exploring unique files should not be killed as early as stuck loops.
|
|
readOnlyExplorationWarning = 24
|
|
readOnlyExplorationCritical = 36
|
|
|
|
// Uniqueness ratio threshold: above this = exploration, below = stuck.
|
|
readOnlyUniquenessThreshold = 0.6
|
|
|
|
// Same-result: same tool returning identical results with different args.
|
|
sameResultWarning = 4
|
|
sameResultCritical = 6
|
|
)
|
|
|
|
// mutatingTools are tools that indicate real progress (write/create/action).
|
|
// exec is excluded: ambiguous (could be ls or rm). It neither resets nor
|
|
// increments the read-only streak.
|
|
// team_tasks is excluded: action-level classification in recordMutation.
|
|
var mutatingTools = map[string]bool{
|
|
"write_file": true, "edit": true, "edit_file": true,
|
|
"spawn": true, "message": true,
|
|
"create_image": true, "create_video": true, "create_audio": true,
|
|
"tts": true, "cron": true, "publish_skill": true,
|
|
"sessions_send": true,
|
|
}
|
|
|
|
// teamTasksReadOnlyActions are team_tasks actions that don't indicate real progress.
|
|
// They increment the read-only streak like read_file/list_files.
|
|
var teamTasksReadOnlyActions = map[string]bool{
|
|
"list": true, "get": true, "search": true,
|
|
}
|
|
|
|
// teamTasksNeutralActions are team_tasks actions that are heartbeat/status only.
|
|
// They neither reset nor increment the read-only streak (like exec).
|
|
var teamTasksNeutralActions = map[string]bool{
|
|
"progress": true,
|
|
}
|
|
|
|
// toolLoopState tracks recent tool calls within a single agent run
|
|
// to detect infinite loops (same tool + same args + same result).
|
|
type toolLoopState struct {
|
|
history []toolCallRecord
|
|
readOnlyStreak int // consecutive non-mutating, non-exec tool calls
|
|
readOnlyUnique int // unique args hashes in current streak
|
|
seenReadArgs map[string]bool // tracks unique read arg hashes for uniqueness ratio
|
|
}
|
|
|
|
type toolCallRecord struct {
|
|
toolName string
|
|
argsHash string
|
|
resultHash string // empty until result is recorded
|
|
}
|
|
|
|
// record adds a tool call to history and returns its argsHash.
|
|
func (s *toolLoopState) record(toolName string, args map[string]any) string {
|
|
h := hashToolCall(toolName, args)
|
|
s.history = append(s.history, toolCallRecord{
|
|
toolName: toolName,
|
|
argsHash: h,
|
|
})
|
|
if len(s.history) > toolLoopHistorySize {
|
|
s.history = s.history[len(s.history)-toolLoopHistorySize:]
|
|
}
|
|
return h
|
|
}
|
|
|
|
// recordResult updates the most recent matching record with the result hash.
|
|
func (s *toolLoopState) recordResult(argsHash, resultContent string) {
|
|
rh := hashResult(resultContent)
|
|
// Walk backward to find the latest record with matching argsHash and no result yet.
|
|
for i := len(s.history) - 1; i >= 0; i-- {
|
|
rec := &s.history[i]
|
|
if rec.argsHash == argsHash && rec.resultHash == "" {
|
|
rec.resultHash = rh
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// detect checks for repeated no-progress tool calls.
|
|
// Returns level ("warning", "critical", or "") and a human-readable message.
|
|
func (s *toolLoopState) detect(toolName string, argsHash string) (level, message string) {
|
|
if len(s.history) < toolLoopWarningThreshold {
|
|
return "", ""
|
|
}
|
|
|
|
// Count records with identical argsHash AND identical non-empty resultHash.
|
|
// This ensures we only flag true no-progress loops (same input → same output).
|
|
var noProgressCount int
|
|
var lastResultHash string
|
|
|
|
for i := len(s.history) - 1; i >= 0; i-- {
|
|
rec := s.history[i]
|
|
if rec.argsHash != argsHash {
|
|
continue
|
|
}
|
|
if rec.resultHash == "" {
|
|
continue // incomplete record, skip
|
|
}
|
|
if lastResultHash == "" {
|
|
lastResultHash = rec.resultHash
|
|
}
|
|
if rec.resultHash == lastResultHash {
|
|
noProgressCount++
|
|
}
|
|
}
|
|
|
|
if noProgressCount >= toolLoopCriticalThreshold {
|
|
return "critical", fmt.Sprintf(
|
|
"CRITICAL: %s has been called %d times with identical arguments and results. "+
|
|
"Stopping to prevent runaway loop.", toolName, noProgressCount)
|
|
}
|
|
|
|
if noProgressCount >= toolLoopWarningThreshold {
|
|
return "warning", fmt.Sprintf(
|
|
"[System: WARNING — %s has been called %d times with the same arguments and identical results. "+
|
|
"This is not making progress. Try a completely different approach, use different tools, "+
|
|
"or respond directly to the user with what you know.]", toolName, noProgressCount)
|
|
}
|
|
|
|
return "", ""
|
|
}
|
|
|
|
// recordMutation updates the read-only streak based on tool type.
|
|
// Mutating tools reset the streak; exec/bash/wait/mcp are neutral; all others increment.
|
|
// team_tasks is classified by action: read-only (list/get/search), neutral (progress),
|
|
// or mutating (create/complete/cancel/comment/etc.).
|
|
func (s *toolLoopState) recordMutation(toolName string, args map[string]any) {
|
|
// team_tasks: action-level classification instead of blanket mutating.
|
|
if toolName == "team_tasks" {
|
|
action, _ := args["action"].(string)
|
|
switch {
|
|
case teamTasksReadOnlyActions[action]:
|
|
s.incrementReadOnly(toolName, args)
|
|
case teamTasksNeutralActions[action]:
|
|
// Heartbeat — no effect on streak.
|
|
case action == "":
|
|
// Missing action arg — treat as neutral to avoid crash.
|
|
default:
|
|
// All other actions (create, complete, cancel, comment, etc.) = mutating.
|
|
s.resetStreak()
|
|
}
|
|
return
|
|
}
|
|
|
|
if mutatingTools[toolName] {
|
|
s.resetStreak()
|
|
return
|
|
}
|
|
// exec/bash: ambiguous (could be ls or rm).
|
|
// wait: intentional delay, neither progress nor read-only scanning.
|
|
// mcp_*: user-defined external tools — GoClaw cannot determine read vs write.
|
|
// Neither reset nor increment the read-only streak.
|
|
if toolName == "exec" || toolName == "bash" || toolName == "wait" || strings.HasPrefix(toolName, "mcp_") {
|
|
return
|
|
}
|
|
s.incrementReadOnly(toolName, args)
|
|
}
|
|
|
|
// resetStreak clears the read-only streak and uniqueness tracking.
|
|
func (s *toolLoopState) resetStreak() {
|
|
s.readOnlyStreak = 0
|
|
s.readOnlyUnique = 0
|
|
s.seenReadArgs = nil
|
|
}
|
|
|
|
// incrementReadOnly increments the read-only streak and tracks uniqueness.
|
|
func (s *toolLoopState) incrementReadOnly(toolName string, args map[string]any) {
|
|
s.readOnlyStreak++
|
|
argsHash := hashToolCall(toolName, args)
|
|
if s.seenReadArgs == nil {
|
|
s.seenReadArgs = make(map[string]bool)
|
|
}
|
|
if !s.seenReadArgs[argsHash] {
|
|
s.seenReadArgs[argsHash] = true
|
|
s.readOnlyUnique++
|
|
}
|
|
}
|
|
|
|
// detectReadOnlyStreak checks for long runs of read-only tool calls
|
|
// without any write/edit action. Uses uniqueness ratio to distinguish
|
|
// exploration (many unique files) from stuck loops (re-reading same files).
|
|
//
|
|
// Stuck mode (ratio ≤ 0.6): warn at 8, kill at 12.
|
|
// Exploration mode (ratio > 0.6): warn at 24, kill at 36.
|
|
func (s *toolLoopState) detectReadOnlyStreak() (level, message string) {
|
|
if s.readOnlyStreak < readOnlyStreakWarning {
|
|
return "", ""
|
|
}
|
|
|
|
uniqueRatio := float64(s.readOnlyUnique) / float64(s.readOnlyStreak)
|
|
|
|
if uniqueRatio > readOnlyUniquenessThreshold {
|
|
// Exploration mode: agent is reading unique files. Relaxed thresholds.
|
|
if s.readOnlyStreak >= readOnlyExplorationCritical {
|
|
return "critical", fmt.Sprintf(
|
|
"CRITICAL: %d consecutive read-only tool calls (%d unique files). "+
|
|
"Stopping — write your findings before reading more.", s.readOnlyStreak, s.readOnlyUnique)
|
|
}
|
|
if s.readOnlyStreak >= readOnlyExplorationWarning {
|
|
return "warning", fmt.Sprintf(
|
|
"[System: You have read %d files (%d unique). "+
|
|
"Summarize what you have learned so far using write_file, then continue reading if needed.]",
|
|
s.readOnlyStreak, s.readOnlyUnique)
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
// Stuck mode: low uniqueness — agent is re-reading same files. Original thresholds.
|
|
if s.readOnlyStreak >= readOnlyStreakCritical {
|
|
return "critical", fmt.Sprintf(
|
|
"CRITICAL: %d consecutive read-only tool calls (only %d unique). "+
|
|
"Stopping — you are re-reading the same files without making progress.", s.readOnlyStreak, s.readOnlyUnique)
|
|
}
|
|
if s.readOnlyStreak >= readOnlyStreakWarning {
|
|
return "warning", fmt.Sprintf(
|
|
"[System: WARNING — You have made %d consecutive read-only tool calls (only %d unique files). "+
|
|
"Stop re-reading and take action with what you have — use edit or write_file, "+
|
|
"or respond to the user if you are stuck.]", s.readOnlyStreak, s.readOnlyUnique)
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
// detectSameResult checks if the same tool returned identical results multiple
|
|
// times with different arguments. This catches loops where the agent varies
|
|
// args slightly but gets no new information.
|
|
func (s *toolLoopState) detectSameResult(toolName, resultHash string) (level, message string) {
|
|
if resultHash == "" {
|
|
return "", ""
|
|
}
|
|
var count int
|
|
for _, rec := range s.history {
|
|
if rec.toolName == toolName && rec.resultHash == resultHash {
|
|
count++
|
|
}
|
|
}
|
|
if count >= sameResultCritical {
|
|
return "critical", fmt.Sprintf(
|
|
"CRITICAL: %s returned identical results %d times (with different arguments). "+
|
|
"Stopping to prevent runaway loop.", toolName, count)
|
|
}
|
|
if count >= sameResultWarning {
|
|
return "warning", fmt.Sprintf(
|
|
"[System: WARNING — %s has returned the same result %d times with different arguments. "+
|
|
"The information is already in your context. Stop re-reading and take action — "+
|
|
"use edit/write_file to modify files, or respond to the user if you are stuck.]",
|
|
toolName, count)
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
// hashToolCall produces a deterministic hash of tool name + arguments.
|
|
func hashToolCall(toolName string, args map[string]any) string {
|
|
s := toolName + ":" + stableJSON(args)
|
|
h := sha256.Sum256([]byte(s))
|
|
return fmt.Sprintf("%x", h[:16]) // 32 hex chars, enough for dedup
|
|
}
|
|
|
|
// hashResult produces a hash of tool result content.
|
|
func hashResult(content string) string {
|
|
h := sha256.Sum256([]byte(content))
|
|
return fmt.Sprintf("%x", h[:16])
|
|
}
|
|
|
|
// stableJSON serializes a value with sorted keys for deterministic hashing.
|
|
func stableJSON(v any) string {
|
|
switch val := v.(type) {
|
|
case map[string]any:
|
|
keys := make([]string, 0, len(val))
|
|
for k := range val {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
parts := make([]string, len(keys))
|
|
for i, k := range keys {
|
|
parts[i] = fmt.Sprintf("%q:%s", k, stableJSON(val[k]))
|
|
}
|
|
return "{" + strings.Join(parts, ",") + "}"
|
|
case []any:
|
|
parts := make([]string, len(val))
|
|
for i, elem := range val {
|
|
parts[i] = stableJSON(elem)
|
|
}
|
|
return "[" + strings.Join(parts, ",") + "]"
|
|
default:
|
|
b, _ := json.Marshal(v)
|
|
return string(b)
|
|
}
|
|
}
|