Repository: mudler/LocalAGI
Pull request #101: fix(core): Add recursive loop detection and move loop detection
Author: login: richiejp
Created: 2025-04-30T09:41:40Z | Merged: 2025-04-30T13:51:43Z

Originally part of the mock LLM PR, but I seperated it out because it shouldn't be held back.



Diff:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index a56b4928..1f47b10a 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -30,16 +30,24 @@ jobs:
           $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
           sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
         sudo apt-get update
-        sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
+        sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin make
         docker version
 
         docker run --rm hello-world
     - uses: actions/setup-go@v5
       with:
         go-version: '>=1.17.0'
+    - name: Free up disk space
+      run: |
+        sudo rm -rf /usr/share/dotnet
+        sudo rm -rf /usr/local/lib/android
+        sudo rm -rf /opt/ghc
+        sudo apt-get clean
+        docker system prune -af || true
+        df -h
+
     - name: Run tests
       run: |
-        sudo apt-get update && sudo apt-get install -y make
         make tests
     #sudo mv coverage/coverage.txt coverage.txt
     #sudo chmod 777 coverage.txt
diff --git a/core/agent/actions.go b/core/agent/actions.go
index 0cae1b32..7b248e7e 100644
--- a/core/agent/actions.go
+++ b/core/agent/actions.go
@@ -480,13 +480,18 @@ func (a *Agent) pickAction(job *types.Job, templ string, messages []openai.ChatC
 		}, c...)
 	}
 
+	reasoningAction := action.NewReasoning()
 	thought, err := a.decision(job,
 		c,
-		types.Actions{action.NewReasoning()}.ToTools(),
-		action.NewReasoning().Definition().Name.String(), maxRetries)
+		types.Actions{reasoningAction}.ToTools(),
+		reasoningAction.Definition().Name.String(), maxRetries)
 	if err != nil {
 		return nil, nil, "", err
 	}
+	if thought.actioName != "" && thought.actioName != reasoningAction.Definition().Name.String() {
+		return nil, nil, "", fmt.Errorf("Expected reasoning action not: %s", thought.actioName)
+	}
+
 	originalReasoning := ""
 	response := &action.ReasoningResponse{}
 	if thought.actionParams != nil {
diff --git a/core/agent/agent.go b/core/agent/agent.go
index 3954651d..0e7eab61 100644
--- a/core/agent/agent.go
+++ b/core/agent/agent.go
@@ -490,13 +490,18 @@ func (a *Agent) processUserInputs(job *types.Job, role string, conv Messages) Me
 	return conv
 }
 
-func (a *Agent) consumeJob(job *types.Job, role string) {
+func (a *Agent) consumeJob(job *types.Job, role string, retries int) {
 
 	if err := job.GetContext().Err(); err != nil {
 		job.Result.Finish(fmt.Errorf("expired"))
 		return
 	}
 
+	if retries < 1 {
+		job.Result.Finish(fmt.Errorf("Exceeded recursive retries"))
+		return
+	}
+
 	a.Lock()
 	paused := a.pause
 	a.Unlock()
@@ -559,7 +564,7 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
 				xlog.Error("Error generating parameters, trying again", "error", err)
 				// try again
 				job.SetNextAction(&chosenAction, nil, reasoning)
-				a.consumeJob(job, role)
+				a.consumeJob(job, role, retries - 1)
 				return
 			}
 			actionParams = p.actionParams
@@ -577,24 +582,6 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
 		}
 	}
 
-	// check if the agent is looping over the same action
-	// if so, we need to stop it
-	if a.options.loopDetectionSteps > 0 && len(job.GetPastActions()) > 0 {
-		count := map[string]int{}
-		for i := len(job.GetPastActions()) - 1; i >= 0; i-- {
-			pastAction := job.GetPastActions()[i]
-			if pastAction.Action.Definition().Name == chosenAction.Definition().Name &&
-				pastAction.Params.String() == actionParams.String() {
-				count[chosenAction.Definition().Name.String()]++
-			}
-		}
-		if count[chosenAction.Definition().Name.String()] > a.options.loopDetectionSteps {
-			xlog.Info("Loop detected, stopping agent", "agent", a.Character.Name, "action", chosenAction.Definition().Name)
-			a.reply(job, role, conv, actionParams, chosenAction, reasoning)
-			return
-		}
-	}
-
 	//xlog.Debug("Picked action", "agent", a.Character.Name, "action", chosenAction.Definition().Name, "reasoning", reasoning)
 	if chosenAction == nil {
 		// If no action was picked up, the reasoning is the message returned by the assistant
@@ -648,7 +635,7 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
 			xlog.Error("Error generating parameters, trying again", "error", err)
 			// try again
 			job.SetNextAction(&chosenAction, nil, reasoning)
-			a.consumeJob(job, role)
+			a.consumeJob(job, role, retries - 1)
 			return
 		}
 		actionParams = params.actionParams
@@ -668,6 +655,22 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
 		return
 	}
 
+	if a.options.loopDetectionSteps > 0 && len(job.GetPastActions()) > 0 {
+		count := 0
+		for _, pastAction := range job.GetPastActions() {
+			if pastAction.Action.Definition().Name == chosenAction.Definition().Name &&
+				pastAction.Params.String() == actionParams.String() {
+				count++
+			}
+		}
+		if count > a.options.loopDetectionSteps {
+			xlog.Info("Loop detected, stopping agent", "agent", a.Character.Name, "action", chosenAction.Definition().Name)
+			a.reply(job, role, conv, actionParams, chosenAction, reasoning)
+			return
+		}
+		xlog.Debug("Checked for loops", "action", chosenAction.Definition().Name, "count", count)
+	}
+
 	job.AddPastAction(chosenAction, &actionParams)
 
 	if !job.Callback(types.ActionCurrentState{
@@ -781,7 +784,7 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
 		// The agent decided to do another action
 		// call ourselves again
 		job.SetNextAction(&followingAction, &followingParams, reasoning)
-		a.consumeJob(job, role)
+		a.consumeJob(job, role, retries)
 		return
 	}
 
@@ -966,7 +969,7 @@ func (a *Agent) periodicallyRun(timer *time.Timer) {
 		types.WithReasoningCallback(a.options.reasoningCallback),
 		types.WithResultCallback(a.options.resultCallback),
 	)
-	a.consumeJob(whatNext, SystemRole)
+	a.consumeJob(whatNext, SystemRole, a.options.loopDetectionSteps)
 
 	xlog.Info("STOP -- Periodically run is done", "agent", a.Character.Name)
 
@@ -1050,7 +1053,7 @@ func (a *Agent) run(timer *time.Timer) error {
 				<-timer.C
 			}
 			xlog.Debug("Agent is consuming a job", "agent", a.Character.Name, "job", job)
-			a.consumeJob(job, UserRole)
+			a.consumeJob(job, UserRole, a.options.loopDetectionSteps)
 			timer.Reset(a.options.periodicRuns)
 		case <-a.context.Done():
 			// Agent has been canceled, return error
diff --git a/core/agent/options.go b/core/agent/options.go
index 9f3f2508..1006dd2f 100644
--- a/core/agent/options.go
+++ b/core/agent/options.go
@@ -68,6 +68,7 @@ func defaultOptions() *options {
 	return &options{
 		parallelJobs: 1,
 		periodicRuns: 15 * time.Minute,
+		loopDetectionSteps: 10,
 		LLMAPI: llmOptions{
 			APIURL: "http://localhost:8080",
 			Model:  "gpt-4",


Reviews:
user: login: mudler
body: nice! totally missed that part

state: APPROVED
author_association: OWNER
submitted_at: 2025-04-30T11:10:36Z
user: login: mudler
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-30T11:10:51Z
user: login: richiejp
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-30T12:28:24Z
user: login: mudler
state: APPROVED
author_association: OWNER
submitted_at: 2025-04-30T12:57:18Z

Review comments:
diff_hunk: @@ -1050,7 +1053,7 @@ func (a *Agent) run(timer *time.Timer) error {
 				<-timer.C
 			}
 			xlog.Debug("Agent is consuming a job", "agent", a.Character.Name, "job", job)
-			a.consumeJob(job, UserRole)
+			a.consumeJob(job, UserRole, 5)
path: core/agent/agent.go
user: login: mudler
body: shall we make it a constant?
created_at: 2025-04-30T11:10:51Z
author_association: OWNER
diff_hunk: @@ -1050,7 +1053,7 @@ func (a *Agent) run(timer *time.Timer) error {
 				<-timer.C
 			}
 			xlog.Debug("Agent is consuming a job", "agent", a.Character.Name, "job", job)
-			a.consumeJob(job, UserRole)
+			a.consumeJob(job, UserRole, 5)
path: core/agent/agent.go
user: login: richiejp
body: I set it to use the loop detection option and turned it on by default, but with a high default of 10.
created_at: 2025-04-30T12:28:24Z
author_association: COLLABORATOR

PR comments:
user: login: richiejp
created_at: 2025-04-30T10:46:07Z
body: Failing due to compilation failure due to lack of space. I fix that also in the mock LLM test pr.
author_association: COLLABORATOR
user: login: richiejp
created_at: 2025-04-30T12:29:03Z
body: I added the free space trick to this pr as well

author_association: COLLABORATOR