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
15 changes: 15 additions & 0 deletions cmd/chat_subcommand_shell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,21 @@ func TestRunSubcommand_BashToolBlocksEnvDumpEvenWhenBypassed(t *testing.T) {
}
}

func TestRunSubcommand_AllowsNamedGoDiagnostics(t *testing.T) {
m := newTestChatModel()
m.session.PermSvc().SetAutonomy(safety.AutonomyYOLO)

result, _ := (&runSubcommand{}).Handle(m, []string{"go", "env", "GOTOOLCHAIN"}, "/run go env GOTOOLCHAIN")
cm := requireChatModel(t, result)
if len(cm.messages) == 0 {
t.Fatal("expected display message")
}
last := cm.messages[len(cm.messages)-1]
if last.role == "error" || strings.Contains(last.content, "blocked:") || !strings.Contains(last.content, "$ go env GOTOOLCHAIN") {
t.Fatalf("safe Go diagnostic failed in CLI: role=%s output=%q", last.role, last.content)
}
}

func TestTestSubcommand_DetectsNonzeroExit(t *testing.T) {
m := newTestChatModel()
m.session.PermSvc().SetAutonomy(safety.AutonomyYOLO)
Expand Down
29 changes: 17 additions & 12 deletions internal/engine/branching/snowball.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type SnowballDetector struct {
}

// NewSnowballDetector creates a detector with the given absolute token ceiling.
// A non-positive ceiling leaves token limits to the configured session budget.
// The default growth threshold is 2.0x (last 3 turns vs first 3 turns).
func NewSnowballDetector(maxTokens int) *SnowballDetector {
return &SnowballDetector{
Expand All @@ -31,13 +32,8 @@ func (sd *SnowballDetector) RecordTurn(tokens int, progress float64) {
sd.turnProgress = append(sd.turnProgress, progress)
}

// IsSnowballing returns true if the last 3 turns consumed threshold-x+ tokens
// compared to the first 3 turns, i.e. token consumption is accelerating without
// a proportional bound. The progress-per-token dimension is intentionally not
// used here: with the coarse progress signal available per turn it collapses
// to a pure growth check and adds no independent information (see RecordTurn
// call sites). Keeping this a honest growth-rate detector avoids a false sense
// of a second, stricter signal.
// IsSnowballing reports rising usage accompanied by declining activity.
// Growing prompts alone are normal during productive work and must not stop it.
func (sd *SnowballDetector) IsSnowballing() bool {
n := len(sd.turnTokens)
if n < 6 {
Expand All @@ -54,14 +50,14 @@ func (sd *SnowballDetector) IsSnowballing() bool {
}

growthRate := float64(lastAvg) / float64(firstAvg)
return growthRate >= sd.threshold
return growthRate >= sd.threshold && sd.progressTrend() == "declining"
}

// ShouldAbort returns true if total tokens exceed maxTokens or the growth rate
// exceeds 3x between the first and last 3-turn windows.
// exceeds 3x between the first and last 3-turn windows while activity declines.
func (sd *SnowballDetector) ShouldAbort() bool {
total := sumInt(sd.turnTokens)
if total > sd.maxTokens {
if sd.maxTokens > 0 && total > sd.maxTokens {
return true
}

Expand All @@ -76,7 +72,16 @@ func (sd *SnowballDetector) ShouldAbort() bool {
return false
}

return float64(lastAvg)/float64(firstAvg) >= 3.0
return float64(lastAvg)/float64(firstAvg) >= 3.0 && sd.progressTrend() == "declining"
}

// StopReason explains the active stop condition without exposing debug output
// or claiming that a large cumulative input count means context is full.
func (sd *SnowballDetector) StopReason() string {
if sd.maxTokens > 0 && sumInt(sd.turnTokens) > sd.maxTokens {
return fmt.Sprintf("Token limit reached (%s/%s total input and output tokens).", formatTokenCount(sumInt(sd.turnTokens)), formatTokenCount(sd.maxTokens))
}
return "Token usage increased sharply while tool activity declined."
}

// Summary returns a human-readable summary of the snowball state.
Expand All @@ -91,7 +96,7 @@ func (sd *SnowballDetector) Summary() string {
firstAvg := avgInt(sd.turnTokens[:3])
lastAvg := avgInt(sd.turnTokens[n-3:])
if firstAvg > 0 {
growthStr = fmt.Sprintf("growing %.1fx/turn", float64(lastAvg)/float64(firstAvg))
growthStr = fmt.Sprintf("%.1fx initial usage", float64(lastAvg)/float64(firstAvg))
} else {
growthStr = "no baseline"
}
Expand Down
18 changes: 18 additions & 0 deletions internal/engine/branching/snowball_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,24 @@ func TestSnowballDetector_ShouldAbort_GrowthRate3x(t *testing.T) {
}
}

func TestSnowballDetectorContinuesProductiveLargeWork(t *testing.T) {
for _, maxTokens := range []int{0, -1} {
sd := NewSnowballDetector(maxTokens)
for range 3 {
sd.RecordTurn(10000, 1)
}
for range 30 {
sd.RecordTurn(40000, 1)
}
if sd.ShouldAbort() || sd.IsSnowballing() {
t.Fatalf("productive work hit a hidden limit: %s", sd.Summary())
}
if strings.Contains(sd.Summary(), "x/turn") {
t.Fatalf("window comparison mislabeled as per-turn growth: %s", sd.Summary())
}
}
}

func TestSnowballDetector_Summary(t *testing.T) {
sd := NewSnowballDetector(100000)

Expand Down
2 changes: 1 addition & 1 deletion internal/engine/lifecycle_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ func NewLifecycleService(log *logger.Logger) *LifecycleService {
return &LifecycleService{
limits: lifecycle.NewLimitTracker(lifecycle.DefaultLimits()),
loopDet: control.NewLoopDetector(10, control.DoomLoopThreshold),
snowball: branching.NewSnowballDetector(500000),
snowball: branching.NewSnowballDetector(0),
beliefs: NewBeliefState(),
backtrack: control.NewBacktrackEngine(),
lifecycle: nil, // constructed in New() with cwd
Expand Down
4 changes: 3 additions & 1 deletion internal/engine/stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,9 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) {
toolTurns := 0 // turns that used tools (for skill distillation)
var toolsUsedSet map[string]bool
var filesModifiedSet map[string]bool
snowball := branching.NewSnowballDetector(500000) // 500K token ceiling
// Explicit session budgets own token limits. Re-reading conversation
// history across turns must not impose an unrelated lifetime ceiling.
snowball := branching.NewSnowballDetector(0)
loopDet := control.NewLoopDetector(10, control.DoomLoopThreshold) // 10-step window, 3 repeats = doom loop

for {
Expand Down
2 changes: 1 addition & 1 deletion internal/engine/stream_guards.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func (s *Session) checkGuardConditions(ctx context.Context, ch chan<- StreamEven
}

if snowball.ShouldAbort() {
ch <- StreamEvent{Type: "content", Content: "\n\n" + snowball.Summary()}
ch <- StreamEvent{Type: "content", Content: "\n\nStopped: " + snowball.StopReason() + " Send a follow-up to continue from this conversation."}
ch <- StreamEvent{Type: "done"}
return false
}
Expand Down
64 changes: 64 additions & 0 deletions internal/engine/stream_review_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package engine

import (
"context"
"fmt"
"strings"
"testing"
"time"

"github.com/GrayCodeAI/reflect/internal/engine/branching"
"github.com/GrayCodeAI/reflect/internal/engine/control"
"github.com/GrayCodeAI/reflect/internal/engine/safety"
"github.com/GrayCodeAI/reflect/internal/tool"
"github.com/GrayCodeAI/reflect/internal/types"
)

func TestReviewContinuesBeyondOldCumulativeTokenCeiling(t *testing.T) {
client := &conversationalStreamClient{resilienceBoundaryClient: &resilienceBoundaryClient{}}
for i := range 3 {
client.streams = append(client.streams, []types.RelayStreamEvent{
{Type: "tool_call", ToolCall: &types.ToolCall{ID: fmt.Sprint(i), Name: "Read", Arguments: map[string]interface{}{"id": i}}},
{Type: "done", StopReason: "tool_use", Usage: &types.RelayUsage{PromptTokens: 200000, CompletionTokens: 10}},
})
}
client.streams = append(client.streams, []types.RelayStreamEvent{{Type: "content", Content: "Review complete."}, {Type: "done", StopReason: "end_turn"}})
s := NewSessionWithClient(client, "test", "test-model", "", tool.NewRegistry(&countedReadTool{}), false)
s.PermSvc().SetAutonomy(safety.AutonomyYOLO)
s.AddUser("Review the code")
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
events, err := s.Stream(ctx)
if err != nil {
t.Fatal(err)
}
var reply strings.Builder
for event := range events {
if event.Type == "error" {
t.Errorf("review failed: %s", event.Content)
}
if event.Type == "content" {
reply.WriteString(event.Content)
}
}
if !strings.Contains(reply.String(), "Review complete.") || len(client.options) != 4 {
t.Fatalf("review stopped early after %d requests: %s", len(client.options), reply.String())
}
}

func TestReviewStopExplainsReasonInCLI(t *testing.T) {
s := newMockSession(newMockClient())
detector := branching.NewSnowballDetector(100)
detector.RecordTurn(101, 1)
ch := make(chan StreamEvent, 2)
if s.checkGuardConditions(t.Context(), ch, 0, detector, control.NewLoopDetector(10, control.DoomLoopThreshold)) {
t.Fatal("explicit token limit not enforced")
}
event := <-ch
if event.Type != "content" || !strings.Contains(event.Content, "Token limit reached") || !strings.Contains(event.Content, "follow-up") || strings.Contains(event.Content, "Recommendation:") || strings.Contains(event.Content, "fresh context") {
t.Fatalf("unhelpful stop message: %+v", event)
}
if event := <-ch; event.Type != "done" {
t.Fatalf("missing terminal event: %+v", event)
}
}
Loading
Loading