Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions cmd/sin-code/internal/agentloop/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,14 @@ type Loop struct {
LocalSpec []ToolSpec
Workspace string
MaxTurns int
SessionID string
Completion func(ctx context.Context, history []session.Message, tools []ToolSpec) (*Completion, error)
// MaxStopRejects caps how many times the stop-gate can reject
// completion before the run errors. Zero falls back to the
// default of 3. Independent of StallThreshold (issue #150):
// MaxStopRejects is a hard count, StallThreshold is an
// identical-criteria fingerprint count.
MaxStopRejects int
SessionID string
Completion func(ctx context.Context, history []session.Message, tools []ToolSpec) (*Completion, error)

Hooks *hooks.Engine
Perm *permission.Engine
Expand All @@ -86,6 +92,11 @@ type Loop struct {
// returning DONE. Optional — nil keeps the legacy single-gate behavior.
StopGate StopGate

// StallThreshold escalates early when the stop-gate returns the SAME set
// of open criteria this many times consecutively (no progress). Zero
// disables stall detection. Recommended: 3. Independent of MaxStopRejects.
StallThreshold int

// AllowContinuation switches the maxTurns outcome from a hard error to a
// checkpointed, resumable Result (Continuation=true). Daemons set this so
// a long task is re-enqueued and resumed rather than abandoned; one-shot
Expand Down Expand Up @@ -245,6 +256,9 @@ func (l *Loop) Run(ctx context.Context, sess *session.Session, prompt string) (*
var pendingInjects []string
var lastText string
var lastOpen []string
stopRejects := 0 // tracks how many times the stop-gate rejected completion
lastCritFingerprint := ""
stallCount := 0
toolsSeen := map[string]bool{}
var toolsUsed []string

Expand Down Expand Up @@ -334,12 +348,38 @@ func (l *Loop) Run(ctx context.Context, sess *session.Session, prompt string) (*
})
if !dec.Complete {
lastOpen = dec.OpenCriteria
stopRejects++
l.fire(ctx, hooks.StopContinue, "", map[string]any{
"open_criteria": dec.OpenCriteria, "report": dec.Report,
})
l.record(ctx, ledger.TypeStopContinue,
map[string]any{"open_criteria": dec.OpenCriteria},
"stop-gate rejected completion; continuing")
// Stagnation guard: identical open criteria across consecutive
// rejects means the worker is stuck. Escalate early.
fp := strings.Join(dec.OpenCriteria, "\x1f")
if fp != "" && fp == lastCritFingerprint {
stallCount++
} else {
stallCount = 1
lastCritFingerprint = fp
}
if l.StallThreshold > 0 && stallCount >= l.StallThreshold {
if serr := sess.SaveHistory(msgs); serr != nil {
return nil, serr
}
l.fire(ctx, hooks.StopStalled, "", map[string]any{
"stall_count": stallCount, "open_criteria": lastOpen,
})
l.record(ctx, ledger.TypeStallDetected,
map[string]any{"stall_count": stallCount, "open_criteria": lastOpen},
fmt.Sprintf("no progress: identical open criteria %d turns in a row; escalating", stallCount))
return nil, fmt.Errorf(
"stop-gate stalled: identical open criteria %d turns in a row "+
"(StallThreshold=%d); open criteria: %s",
stallCount, l.StallThreshold, strings.Join(lastOpen, "; "),
)
}
if l.Lessons != nil {
_ = l.Lessons.Record(ctx, lessons.Entry{
Type: lessons.TypeFailedVerification,
Expand All @@ -355,6 +395,17 @@ func (l *Loop) Run(ctx context.Context, sess *session.Session, prompt string) (*
if err := sess.SaveHistory(msgs); err != nil {
return nil, err
}
// Hard cap on stop-gate rejections. Independent of
// StallThreshold (issue #150): MaxStopRejects is a
// straight count, stall is a fingerprint match.
maxRejects := l.MaxStopRejects
if maxRejects <= 0 {
maxRejects = 3
}
if stopRejects >= maxRejects {
return nil, fmt.Errorf("stop-gate rejected completion %d times (max %d); open criteria: %s",
stopRejects, maxRejects, strings.Join(lastOpen, "; "))
}
continue
}
}
Expand Down
109 changes: 109 additions & 0 deletions cmd/sin-code/internal/agentloop/loop_stall_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// SPDX-License-Identifier: MIT
// Purpose: tests for issue #150 — Stagnations-Erkennung + adaptives
// Stop-Budget. The stop-gate returning identical open criteria N
// times in a row is treated as a stall and escalates early.
package agentloop

import (
"context"
"strings"
"testing"

"github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/session"
)

// Stall detection fires before MaxStopRejects when criteria never change.
func TestStopGate_Stall_EscalatesEarly(t *testing.T) {
s := setupSession(t)
calls := 0
loop := &Loop{
Gate: passGate(),
Workspace: "/tmp",
MaxStopRejects: 100, // high — must NOT be the trigger
StallThreshold: 3,
StopGate: func(ctx context.Context, snap StopSnapshot) StopDecision {
calls++
return StopDecision{Complete: false, OpenCriteria: []string{"SAME_CRIT"}}
},
Completion: func(ctx context.Context, msgs []session.Message, tools []ToolSpec) (*Completion, error) {
return &Completion{Text: "done", Raw: session.Message{Role: "assistant", Content: "done"}}, nil
},
}
_, err := loop.Run(context.Background(), s, "stall")
if err == nil || !strings.Contains(err.Error(), "stalled") {
t.Fatalf("expected stall escalation, got: %v", err)
}
if calls != 3 {
t.Fatalf("expected escalation at StallThreshold=3, got %d calls", calls)
}
}

// Changing criteria between rejects resets the stall counter.
func TestStopGate_Stall_ChangingCriteriaResets(t *testing.T) {
s := setupSession(t)
calls := 0
loop := &Loop{
Gate: passGate(),
Workspace: "/tmp",
MaxStopRejects: 100,
StallThreshold: 3,
StopGate: func(ctx context.Context, snap StopSnapshot) StopDecision {
calls++
// alternate between two different criteria sets; never
// stalls. Eventually MaxStopRejects hits.
if calls%2 == 1 {
return StopDecision{Complete: false, OpenCriteria: []string{"CRIT_A"}}
}
return StopDecision{Complete: false, OpenCriteria: []string{"CRIT_B"}}
},
Completion: func(ctx context.Context, msgs []session.Message, tools []ToolSpec) (*Completion, error) {
return &Completion{Text: "done", Raw: session.Message{Role: "assistant", Content: "done"}}, nil
},
}
_, err := loop.Run(context.Background(), s, "x")
// Expected outcome: MaxStopRejects (default 3) eventually hits
// because the criteria keep changing (no stall) and the gate
// keeps rejecting. The exact error wording depends on the
// stopRejects vs MaxStopRejects interaction; we just need to
// verify a non-nil error and that the gate was consulted
// several times.
if err == nil {
t.Fatal("expected error after repeated rejections")
}
if calls < 3 {
t.Fatalf("expected at least 3 gate calls, got %d", calls)
}
// Must NOT be a stall error (the criteria changed).
if strings.Contains(err.Error(), "stalled") {
t.Fatalf("did not expect stall escalation when criteria change, got: %v", err)
}
}

// StallThreshold=0 disables the detection (backwards compatible).
func TestStopGate_Stall_Disabled(t *testing.T) {
s := setupSession(t)
calls := 0
loop := &Loop{
Gate: passGate(),
Workspace: "/tmp",
MaxStopRejects: 3, // stops via MaxStopRejects
StallThreshold: 0, // disabled
StopGate: func(ctx context.Context, snap StopSnapshot) StopDecision {
calls++
return StopDecision{Complete: false, OpenCriteria: []string{"SAME_CRIT"}}
},
Completion: func(ctx context.Context, msgs []session.Message, tools []ToolSpec) (*Completion, error) {
return &Completion{Text: "done", Raw: session.Message{Role: "assistant", Content: "done"}}, nil
},
}
_, err := loop.Run(context.Background(), s, "x")
if err == nil {
t.Fatal("expected error from MaxStopRejects path")
}
if strings.Contains(err.Error(), "stalled") {
t.Fatalf("did not expect stall error when StallThreshold=0, got: %v", err)
}
if calls != 3 {
t.Fatalf("expected exactly 3 gate calls (MaxStopRejects=3), got %d", calls)
}
}
3 changes: 3 additions & 0 deletions cmd/sin-code/internal/hooks/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ const (
// rejects a proposed completion and forces the loop to keep working.
StopEval = "stop.eval"
StopContinue = "stop.continue"
// StopStalled fires when the stop-gate returns identical open criteria
// StallThreshold turns in a row (no-progress escalation).
StopStalled = "stop.stalled"

AgentSpawn = "agent.spawn"
AgentComplete = "agent.complete"
Expand Down
4 changes: 4 additions & 0 deletions cmd/sin-code/internal/ledger/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ const (
TypeTaskAbort EntryType = "task_abort"
TypeStopContinue EntryType = "stop_continue"
TypeTaskCheckpoint EntryType = "task_checkpoint"
// TypeStallDetected is recorded when the stop-gate returns the SAME open
// criteria StallThreshold times in a row — the worker is not making
// progress and the run escalates early instead of burning the full budget.
TypeStallDetected EntryType = "stall_detected"
)

// Entry is one row in the ledger.
Expand Down
Loading