From 2968c27a25eaa04025b0ad2f284690eced8a55f6 Mon Sep 17 00:00:00 2001 From: opencode Date: Tue, 16 Jun 2026 21:55:02 +0200 Subject: [PATCH] feat(agentloop): Stagnations-Erkennung + adaptives Stop-Budget (issue #150) What ships: - cmd/sin-code/internal/agentloop/loop.go: - New Loop.StallThreshold field: 0 disables (backwards-compatible); recommended 3. - New Loop.MaxStopRejects field: hard cap on stop-gate rejections, default 3. - State: stopRejects counter + lastCritFingerprint (joined OpenCriteria with \x1f separator) + stallCount. - Reject-block: tracks consecutive identical OpenCriteria, escalates early with 'stop-gate stalled' error when stallCount >= StallThreshold. Fires hooks.StopStalled + records ledger.TypeStallDetected. - Reject-block: enforces MaxStopRejects with explicit error message. - cmd/sin-code/internal/ledger/store.go: new EntryType TypeStallDetected. - cmd/sin-code/internal/hooks/hooks.go: new event hooks.StopStalled. - cmd/sin-code/internal/agentloop/loop_stall_test.go: 3 new tests (escalate, change-resets, disabled). Acceptance criteria (from #150): - [x] Identical OpenCriteria over StallThreshold turns -> early abort with 'stalled' error. - [x] Changing criteria reset the stall counter. - [x] StallThreshold=0 disables detection (backwards-compatible). - [x] go test ./cmd/sin-code/internal/agentloop/... all green. Hard mandates honored: - M2 (single binary, no CGO): no new deps. - M3 (verify gate sacred): stall fires after the verify-gate, never short-circuits it. - M7 (race-clean): 3/3 new tests pass under go test -race -count=1. Refs: OpenSIN-Code/SIN-Code#150 --- cmd/sin-code/internal/agentloop/loop.go | 55 ++++++++- .../internal/agentloop/loop_stall_test.go | 109 ++++++++++++++++++ cmd/sin-code/internal/hooks/hooks.go | 3 + cmd/sin-code/internal/ledger/store.go | 4 + 4 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 cmd/sin-code/internal/agentloop/loop_stall_test.go diff --git a/cmd/sin-code/internal/agentloop/loop.go b/cmd/sin-code/internal/agentloop/loop.go index e60c63bf..500cd40c 100644 --- a/cmd/sin-code/internal/agentloop/loop.go +++ b/cmd/sin-code/internal/agentloop/loop.go @@ -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 @@ -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 @@ -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 @@ -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, @@ -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 } } diff --git a/cmd/sin-code/internal/agentloop/loop_stall_test.go b/cmd/sin-code/internal/agentloop/loop_stall_test.go new file mode 100644 index 00000000..0cbe574c --- /dev/null +++ b/cmd/sin-code/internal/agentloop/loop_stall_test.go @@ -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) + } +} diff --git a/cmd/sin-code/internal/hooks/hooks.go b/cmd/sin-code/internal/hooks/hooks.go index 134d7477..5f176832 100644 --- a/cmd/sin-code/internal/hooks/hooks.go +++ b/cmd/sin-code/internal/hooks/hooks.go @@ -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" diff --git a/cmd/sin-code/internal/ledger/store.go b/cmd/sin-code/internal/ledger/store.go index 88f822ef..e3faeda4 100644 --- a/cmd/sin-code/internal/ledger/store.go +++ b/cmd/sin-code/internal/ledger/store.go @@ -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.