Files

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)
}
}