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
7 changes: 0 additions & 7 deletions internal/agent/coordinator.go
Original file line number Diff line number Diff line change
Expand Up @@ -645,13 +645,6 @@ func plannerResearchPauseDetail(err error) string {
maxPause.key,
)
}
var stallPause *todoStallPause
if errors.As(err, &stallPause) {
return fmt.Sprintf(
"planner did not finalize after %d tool-call rounds without progress",
stallPause.rounds,
)
}
return "planner did not finalize after its bounded research and finalization rounds"
}

Expand Down
8 changes: 0 additions & 8 deletions internal/agent/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,6 @@ func PauseClass(err error) string {
if errors.As(err, &maxSteps) {
return "max_steps"
}
var stall *todoStallPause
if errors.As(err, &stall) {
return "todo_stall"
}
var stuck *goalStuckPause
if errors.As(err, &stuck) {
return "goal_stuck"
Expand Down Expand Up @@ -58,10 +54,6 @@ func InspectRunPause(err error) (RunPauseInfo, bool) {
if errors.As(err, &stuck) {
return RunPauseInfo{Kind: "goal_stuck", Limit: stuck.limit, Key: stuck.key, HostOwned: true, Reason: stuck.reason}, true
}
var stall *todoStallPause
if errors.As(err, &stall) {
return RunPauseInfo{Kind: "todo_stall", Limit: stall.rounds, Key: "todo progress", HostOwned: true, Reason: "the current todo made no host-observed progress"}, true
}
var budget *taskBudgetPause
if errors.As(err, &budget) {
return RunPauseInfo{Kind: "task_budget", Key: budget.axis, HostOwned: true, Reason: budget.detail}, true
Expand Down
1 change: 0 additions & 1 deletion internal/agent/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ func TestPauseClassNamesEachGuard(t *testing.T) {
want string
}{
{&maxStepsPause{steps: 40, key: "max_steps"}, "max_steps"},
{&todoStallPause{rounds: 12}, "todo_stall"},
{&FinalReadinessError{Attempts: 3}, "final_readiness"},
{&RecoveryPauseError{Message: "paused"}, "recovery_paused"},
{fmt.Errorf("wrapped: %w", &maxStepsPause{steps: 5}), "max_steps"},
Expand Down
27 changes: 8 additions & 19 deletions internal/agent/goal_run_boundary.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,11 @@ func (e *goalStuckPause) Error() string {
return "goal paused after a structural no-progress loop: " + e.reason
}

type todoStallPause struct {
rounds int
}

func (e *todoStallPause) Error() string {
return fmt.Sprintf("paused after %d tool-call rounds without advancing the current todo — the work so far is saved; inspect the blocker or send another message to continue", e.rounds)
}

func isToolLoopPause(err error) bool {
var maxPause *maxStepsPause
var stallPause *todoStallPause
var stuckPause *goalStuckPause
var budgetPause *taskBudgetPause
return errors.As(err, &maxPause) || errors.As(err, &stallPause) ||
errors.As(err, &stuckPause) || errors.As(err, &budgetPause)
return errors.As(err, &maxPause) || errors.As(err, &stuckPause) || errors.As(err, &budgetPause)
}

// HostProgressSignatures exposes successful evidence identities to the Goal FSM.
Expand Down Expand Up @@ -100,9 +90,14 @@ func (a *Agent) stopUnexecutedBoundaryCalls(state *runLoopState, calls []provide
}
}

func (a *Agent) trackTodoProgress(state *runLoopState, receiptMark int) error {
// trackTodoProgress advances the stall streak and asks the model to reassess
// once, at the checkpoint. It never ends a run: the zero-evidence ladder and
// the storm breaker already own that decision on the same receipts, and they
// reach it far earlier, so a second stop keyed to a todo only added a way for
// the host to end a turn the user never asked it to end.
func (a *Agent) trackTodoProgress(state *runLoopState, receiptMark int) {
if a.planMode.Load() {
return nil
return
}
nextProgress, nextTracking := a.canonicalTodoProgress()
hostProgress := false
Expand All @@ -126,12 +121,6 @@ func (a *Agent) trackTodoProgress(state *runLoopState, receiptMark int) error {
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeLoopGuard,
Text: loopGuardNoticeText(), Detail: fmt.Sprintf("the current todo has no new completion, unique read, command, or mutation for %d consecutive tool-call rounds; asking the assistant to reassess", state.todoStallRounds)})
}
if state.todoStallRounds < maxTodoStallRounds {
return nil
}
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeLoopGuard,
Text: "Task progress stalled; pausing before more tools are called.", Detail: fmt.Sprintf("the current todo has no new completion, unique read, command, or mutation for %d consecutive tool-call rounds after a host reassessment; work is saved and can be resumed", state.todoStallRounds)})
return &todoStallPause{rounds: state.todoStallRounds}
}

func (a *Agent) armGoalStuckFinalization(state *runLoopState, stuck goalStuckSignal) bool {
Expand Down
4 changes: 1 addition & 3 deletions internal/agent/run_loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -640,9 +640,7 @@ func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step i
nudge := fmt.Sprintf("The following tools are unavailable in the current workflow phase: %s. Do not call them again. Respond to the user's request with visible answer text now; call a different tool only if it is still needed to complete the request.", strings.Join(unavailableContextTools, ", "))
a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(nudge)})
}
if err := a.trackTodoProgress(state, receiptMark); err != nil {
return false, err
}
a.trackTodoProgress(state, receiptMark)
if a.armGoalStuckFinalization(state, batch.goalStuck) {
return true, nil
}
Expand Down
3 changes: 0 additions & 3 deletions internal/agent/storm_breaker.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,6 @@ const (
// todoProgressNudgeRounds is the first adaptive checkpoint. The host asks
// the model to reassess, but keeps the turn alive so it can recover.
todoProgressNudgeRounds = 8
// maxTodoStallRounds pauses only after the reassessment also failed to
// produce a new completion or unique host-observed work receipt.
maxTodoStallRounds = 16
)

func todoProgressNudgeMessage(rounds int) string {
Expand Down
60 changes: 46 additions & 14 deletions internal/agent/todo_progress_guard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,34 +15,66 @@ import (
_ "reasonix/internal/tool/builtin"
)

func TestTodoProgressGuardPausesSemanticToolDrift(t *testing.T) {
// stalledTodoTurns drives a todo that never advances: the first unique read
// renews the lease, exact repeats after it do not.
func stalledTodoTurns(extra int) []testutil.Turn {
turns := []testutil.Turn{{ToolCalls: []provider.ToolCall{{
ID: "todo", Name: "todo_write",
Arguments: `{"todos":[{"content":"finish the task","status":"in_progress"}]}`,
}}}}
// The first unique read renews the lease; exact repeats after it do not.
for i := range maxTodoStallRounds + 1 {
for i := range todoProgressNudgeRounds*2 + extra {
turns = append(turns, testutil.Turn{ToolCalls: []provider.ToolCall{{
ID: fmt.Sprintf("read-%d", i), Name: "inspect", Arguments: `{"path":"same"}`,
}}})
}
return turns
}

func stalledTodoAgent(t *testing.T, turns []testutil.Turn) (*Agent, *testutil.MockProvider) {
t.Helper()
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "inspect", readOnly: true})
reg.Add(mustBuiltinTool(t, "todo_write"))
mp := testutil.NewMock("m", turns...)
a := New(mp, reg, NewSession(""), Options{}, event.Discard)
return New(mp, reg, NewSession(""), Options{}, event.Discard), mp
}

err := a.Run(context.Background(), "work until the todo is complete")
var pause *todoStallPause
if !errors.As(err, &pause) {
t.Fatalf("Run error = %v, want todoStallPause", err)
}
if mp.CallCount() != maxTodoStallRounds+2 {
t.Fatalf("provider calls = %d, want %d", mp.CallCount(), maxTodoStallRounds+2)
}
if !sessionContains(a, "Host progress check") {
t.Fatal("semantic drift did not receive the adaptive progress nudge")
// A stalled todo never ends a run, under Goal or ordinary chat. The model is
// asked to reassess once; what it does after that is its own call, and the
// zero-evidence ladder already owns the structural stop on the same receipts.
func TestTodoProgressGuardNeverPausesARun(t *testing.T) {
for _, tc := range []struct {
name string
ctx func() context.Context
}{
{"chat", context.Background},
{"goal", func() context.Context {
return WithDeliveryExecutionScope(context.Background(), DeliveryExecutionScope{ID: "goal-1"})
}},
} {
t.Run(tc.name, func(t *testing.T) {
turns := append(stalledTodoTurns(4), testutil.Turn{Text: "Done."})
a, mp := stalledTodoAgent(t, turns)

err := a.Run(tc.ctx(), "work until the todo is complete")
if err != nil && !isToolLoopPause(err) {
t.Fatalf("Run error = %v", err)
}
if err != nil {
// Goal's structural guard may stop first on its own terms; what
// must not exist is a stop keyed to the todo streak.
if got := PauseClass(err); got == "todo_stall" {
t.Fatalf("pause class = %q, want the todo stall pause gone", got)
}
return
}
if got, want := mp.CallCount(), len(turns); got != want {
t.Fatalf("provider calls = %d, want all %d turns to run past the old threshold", got, want)
}
if !sessionContains(a, "Host progress check") {
t.Fatal("the reassessment nudge went missing; only the pause was meant to go")
}
})
}
}

Expand Down
3 changes: 0 additions & 3 deletions internal/control/turn_orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -520,9 +520,6 @@ func goalPauseFromRunError(err error) (cause, reason string, ok bool) {
reason = "host-detected structural no-progress loop"
}
return stopCauseGoalStuck, reason, true
case info.Kind == "todo_stall" && info.HostOwned:
return stopCauseGoalStuck,
fmt.Sprintf("current todo stalled for %d model rounds without host-observed progress", info.Limit), true
default:
return "", "", false
}
Expand Down