refactor(agent): stop counting rounds, and let the executor decide when to plan

Two ladders came down.

The planner gate was scoring task text out of twelve keyword tables — work,
high-risk, cross-surface, ambiguous, atomic — and buying a second model round
from vocabulary. "migrate" was enough. So was a message over 240 runes, or two
file paths in one sentence. What the tables were reaching for is a judgment the
executor is better placed to make: it reads the same words with the repository
in front of it, and lays out steps with todo_write when a task needs them,
where the delivery gates and the contract can both see them. The gate now
answers only "did the user ask for a plan?" — plan mode, a synthetic turn, a
slash command, or a directive the user stated outright ("先规划再执行", "wait
for my approval"). taskpolicy loses the surface-shape signals that fed it for
the same reason: rune counts and path counts described the sentence, not the
work. Risk stays a host signal, and nothing derives one from wording.

The six per-round counters came down with it. A turn was being watched by the
zero-gain ladder (2/4/6), the storm breaker (3 identical failures, 3 all-blocked
rounds), the repeat-failure and repeat-success blocks (2 each), and the todo
stall checkpoints (8/16) — all reading the same receipts, all reaching for the
same "it looks stuck" from a different angle, and each one able to redirect the
model or refuse its call. run_budget.go already says why this was the wrong
axis: rounds are a poor proxy, and the failures worth stopping are reported in
hours and tokens. Those budgets remain, off by default and user-owned. Stopping
a task stays the user's call.

Removing them retires the loop-guard pass in final readiness too — an escape
hatch whose only job was to release a model the guards had cornered.

The outcome scorer survives as an instrument (internal/agent/outcome_shadow.go):
it still decomposes each round for the bench, and still decides nothing.

Net -3250 lines.

Cache-impact: none - the system-prompt prefix is untouched; the per-turn
<execution-policy> block keeps its shape and only ever carried preset-derived
values, which are unchanged for every preset.
Cache-guard: go test ./internal/boot/ (prefix_shape golden unchanged)
Documentation-impact: none - no docs/*.md describes the planner scoring or the
round counters; user-facing copy for plan mode and task budgets is unchanged.
This commit is contained in:
esengine committed 2026-08-14 18:48:36 -07:00
1 parent b071fef950
commit 21638e356c
32 files changed
+263 -3513

No files matched your search

-234
View File
@@ -1,7 +1,6 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -12,8 +11,6 @@ import (
"time"
"unicode/utf8"
"mvdan.cc/sh/v3/syntax"
"reasonix/internal/ablation"
"reasonix/internal/agentpreset"
"reasonix/internal/capability"
@@ -30,7 +27,6 @@ import (
"reasonix/internal/planmode"
"reasonix/internal/provider"
"reasonix/internal/sandbox"
"reasonix/internal/shellparse"
"reasonix/internal/taskpolicy"
"reasonix/internal/tool"
"reasonix/internal/workspacelease"
@@ -404,13 +400,6 @@ type Agent struct {
activeTurnCreatedAt atomic.Int64
}
type repeatFailureRecord struct {
count int
errClass string
paths []string
stateRecheck bool
}
// KeepPolicy is a bitmask controlling which messages are preserved beyond the
// recent tail during compaction.
type KeepPolicy int
@@ -611,8 +600,6 @@ func (a *Agent) SetSession(s *Session) {
a.sess.reset(s)
// The replaced conversation's task is over, but the ledger and the bill
// answer to beginRunTurn's scope check rather than to this seam.
a.task.repeatFailures = nil
a.task.repeatScope = ""
if s != nil {
a.rebuildTodoState(s.Snapshot())
}
@@ -1405,22 +1392,6 @@ func (a *Agent) hasActiveCanonicalTodo() bool {
return false
}
func (a *Agent) canonicalTodoProgress() (int, bool) {
a.sess.todoMu.Lock()
defer a.sess.todoMu.Unlock()
completed := 0
incomplete := false
for _, todo := range a.sess.todoState {
status := canonicalTodoStatus(todo.Status)
if status == "completed" {
completed++
} else {
incomplete = true
}
}
return completed, incomplete
}
func (a *Agent) advanceCanonicalTodo(step string) {
a.sess.todoMu.Lock()
if len(a.sess.todoState) == 0 {
@@ -2468,20 +2439,6 @@ func (a *Agent) planModeDecision(toolName string, readOnly bool, safety planmode
})
}
func (a *Agent) repeatedSuccessBlock(call provider.ToolCall, t tool.Tool) (string, bool) {
sig, ok := repeatSuccessSignature(call, t)
if !ok || a.turn.repeatSuccessCounts == nil {
return "", false
}
count := a.turn.repeatSuccessCounts[sig]
if count < repeatSuccessBreakThreshold {
return "", false
}
return fmt.Sprintf(
"blocked: [loop guard] %q has already succeeded %d times with the same write-like arguments in this user turn. Re-running it is unlikely to help and may burn tokens or repeat file writes. Change approach: use edit_file or multi_edit for file changes, verify with a read/test command, or explain the blocker in your final answer.",
call.Name, count), true
}
func (a *Agent) staleAnchorEditBlock(call provider.ToolCall) (string, bool) {
if a.task.ledger == nil || !anchorBasedEditTool(call.Name) {
return "", false
@@ -2513,197 +2470,6 @@ func anchorBasedEditTool(name string) bool {
}
}
func (a *Agent) recordRepeatSuccess(call provider.ToolCall, t tool.Tool) {
sig, ok := repeatSuccessSignature(call, t)
if !ok {
return
}
if a.turn.repeatSuccessCounts == nil {
a.turn.repeatSuccessCounts = make(map[string]int)
}
a.turn.repeatSuccessCounts[sig]++
}
func repeatSuccessSignature(call provider.ToolCall, t tool.Tool) (string, bool) {
if t.ReadOnly() {
return "", false
}
switch call.Name {
case "write_file", "edit_file", "multi_edit", "move_file", "notebook_edit":
return call.Name + "\x00" + canonicalToolArgs(call.Arguments), true
case "bash":
var p struct {
Command string `json:"command"`
RunInBackground bool `json:"run_in_background"`
}
if err := json.Unmarshal([]byte(call.Arguments), &p); err != nil {
return "", false
}
if p.RunInBackground || !isShellFileWriteCommand(p.Command) {
return "", false
}
return "bash\x00" + normalizeShellCommand(p.Command), true
default:
return "", false
}
}
func canonicalToolArgs(raw string) string {
var v any
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return strings.TrimSpace(raw)
}
b, err := json.Marshal(v)
if err != nil {
return strings.TrimSpace(raw)
}
var compact bytes.Buffer
if err := json.Compact(&compact, b); err != nil {
return string(b)
}
return compact.String()
}
func normalizeShellCommand(command string) string {
if fields, malformed := shellparse.StaticFields(command); malformed == "" && len(fields) > 0 {
return strings.Join(fields, " ")
}
return strings.Join(strings.Fields(command), " ")
}
func isShellFileWriteCommand(command string) bool {
lower := strings.ToLower(command)
switch {
case shellPythonOpenWrites(lower):
return true
case strings.Contains(lower, "set-content") || strings.Contains(lower, "add-content") || strings.Contains(lower, "out-file"):
return true
case strings.Contains(lower, "sed -i") || strings.Contains(lower, "perl -pi"):
return true
case hasShellWriteRedirect(command):
return true
default:
return false
}
}
func shellPythonOpenWrites(lower string) bool {
if !strings.Contains(lower, "open(") {
return false
}
if strings.Contains(lower, ".write(") {
return true
}
for _, marker := range []string{", 'w", `, "w`, ", 'a", `, "a`, ", 'x", `, "x`, "mode='w", `mode="w`, "mode='a", `mode="a`, "mode='x", `mode="x`} {
if strings.Contains(lower, marker) {
return true
}
}
return false
}
func hasShellWriteRedirect(command string) bool {
file, err := shellparse.ParseBash(command)
if err == nil {
hasWrite := false
syntax.Walk(file, func(node syntax.Node) bool {
redir, ok := node.(*syntax.Redirect)
if !ok {
return true
}
if bashRedirectWritesFile(command, redir) {
hasWrite = true
return false
}
return true
})
return hasWrite
}
return hasShellWriteRedirectFallback(command)
}
func bashRedirectWritesFile(source string, redir *syntax.Redirect) bool {
if redir == nil {
return false
}
switch redir.Op {
case syntax.RdrOut, syntax.AppOut, syntax.RdrClob, syntax.AppClob,
syntax.RdrAll, syntax.RdrAllClob, syntax.AppAll, syntax.AppAllClob,
syntax.RdrInOut:
return !redirectWordIsNullSink(source, redir.Word)
default:
return false
}
}
func redirectWordIsNullSink(source string, word *syntax.Word) bool {
if word == nil {
return false
}
if value, ok := shellparse.StaticWord(word); ok {
if isNullSinkWord(strings.TrimSpace(value)) {
return true
}
}
value := strings.TrimSpace(redirectWordSource(source, word))
if isNullSinkWord(value) {
return true
}
if len(value) >= 2 && ((value[0] == '\'' && value[len(value)-1] == '\'') || (value[0] == '"' && value[len(value)-1] == '"')) {
return isNullSinkWord(value[1 : len(value)-1])
}
return false
}
func isNullSinkWord(value string) bool {
if value == "/dev/null" {
return true
}
return strings.EqualFold(value, "$null") || strings.EqualFold(value, "nul")
}
func redirectWordSource(source string, word *syntax.Word) string {
if word == nil || !word.Pos().IsValid() || !word.End().IsValid() {
return ""
}
start := int(word.Pos().Offset())
end := int(word.End().Offset())
if start < 0 || end < start || end > len(source) {
return ""
}
return source[start:end]
}
func hasShellWriteRedirectFallback(command string) bool {
var quote rune
var prev rune
for _, r := range command {
if quote != 0 {
if r == quote {
quote = 0
}
prev = r
continue
}
if r == '\'' || r == '"' {
quote = r
prev = r
continue
}
if r == '>' {
if prev == '2' {
prev = r
continue
}
return true
}
prev = r
}
return false
}
// isBackgroundTaskCall reports whether a `task` call set run_in_background, so a
// fire-and-return dispatch isn't mistaken for a sub-agent that has stopped.
func isBackgroundTaskCall(args string) bool {
var p struct {
RunInBackground bool `json:"run_in_background"`
-65
View File
@@ -1,65 +0,0 @@
package agent
import (
"strings"
"reasonix/internal/event"
)
// verdict is the escalation ladder every run signal speaks. A round reduces its
// signals to the strongest one, so a land raised beside an advise still lands.
type verdict int
const (
verdictContinue verdict = iota
verdictAdvise
verdictRedirect
verdictLand
)
// intervention is one signal's decision for a round. Guidance reaches the model
// as environment feedback on the round tail rather than as a synthesized user
// turn: the host is not the user, and a fake user turn competes with real user
// text when compaction decides what to keep.
type intervention struct {
verdict verdict
guidance string
notice *event.Event
}
func (i intervention) fired() bool {
return i.verdict != verdictContinue || i.guidance != "" || i.notice != nil
}
// applyInterventions folds a round's signals into one outcome: the strongest
// verdict, every notice, and all guidance appended to the round tail in signal
// order. Signals used to write that slot directly from outcomes[0].output, so
// two firing in the same round silently dropped the earlier one's guidance.
func (a *Agent) applyInterventions(results []string, outcomes []toolOutcome, ivs ...intervention) verdict {
strongest := verdictContinue
guidance := make([]string, 0, len(ivs))
for _, iv := range ivs {
if !iv.fired() {
continue
}
if iv.verdict > strongest {
strongest = iv.verdict
}
if iv.notice != nil {
a.svc.sink.Emit(*iv.notice)
}
if iv.guidance != "" {
guidance = append(guidance, iv.guidance)
}
}
if len(guidance) == 0 || len(results) == 0 || len(outcomes) == 0 {
return strongest
}
results[0] = outcomes[0].output + "\n\n" + strings.Join(guidance, "\n\n")
return strongest
}
// noticeFor builds the frontend notice a signal emits alongside its guidance.
func noticeFor(code string, level event.Level, text, detail string) *event.Event {
return &event.Event{Kind: event.Notice, Level: level, Code: code, Text: text, Detail: detail}
}
-73
View File
@@ -1,73 +0,0 @@
package agent
import (
"strings"
"testing"
"reasonix/internal/event"
)
// noticeSink collects what the arbiter delivered to the frontend.
type noticeSink struct {
notices []event.Event
}
func (s *noticeSink) Emit(e event.Event) { s.notices = append(s.notices, e) }
func arbiterRound() ([]string, []toolOutcome) {
return []string{"tool output"}, []toolOutcome{{output: "tool output"}}
}
// Every signal used to write results[0] from outcomes[0].output, so a round
// that fired two of them delivered only the last one's guidance.
func TestApplyInterventionsKeepsEveryFiredGuidance(t *testing.T) {
sink := &noticeSink{}
a := &Agent{svc: agentServices{sink: sink}}
results, outcomes := arbiterRound()
got := a.applyInterventions(results, outcomes,
intervention{verdict: verdictRedirect, guidance: "[loop guard] change approach",
notice: noticeFor(event.NoticeCodeLoopGuard, event.LevelInfo, "loop", "loop detail")},
intervention{verdict: verdictAdvise, guidance: "[evidence nudge] verify first",
notice: noticeFor(event.NoticeCodeEvidenceNudge, event.LevelInfo, "evidence", "evidence detail")},
)
if got != verdictRedirect {
t.Fatalf("verdict = %d, want the strongest (%d)", got, verdictRedirect)
}
for _, want := range []string{"tool output", "[loop guard] change approach", "[evidence nudge] verify first"} {
if !strings.Contains(results[0], want) {
t.Fatalf("results[0] = %q, missing %q", results[0], want)
}
}
if len(sink.notices) != 2 {
t.Fatalf("notices = %d, want one per fired signal", len(sink.notices))
}
}
func TestApplyInterventionsLeavesQuietRoundsUntouched(t *testing.T) {
sink := &noticeSink{}
a := &Agent{svc: agentServices{sink: sink}}
results, outcomes := arbiterRound()
if got := a.applyInterventions(results, outcomes, intervention{}, intervention{}); got != verdictContinue {
t.Fatalf("verdict = %d, want continue", got)
}
if results[0] != "tool output" || len(sink.notices) != 0 {
t.Fatalf("results = %q notices = %d, want an untouched round", results[0], len(sink.notices))
}
}
// A land must survive being raised beside a lower tier in the same round.
func TestApplyInterventionsTakesTheStrongestVerdict(t *testing.T) {
a := &Agent{svc: agentServices{sink: &noticeSink{}}}
results, outcomes := arbiterRound()
got := a.applyInterventions(results, outcomes,
intervention{verdict: verdictLand, guidance: "land now"},
intervention{verdict: verdictAdvise, guidance: "just a nudge"},
)
if got != verdictLand {
t.Fatalf("verdict = %d, want land (%d)", got, verdictLand)
}
}
-133
View File
@@ -631,139 +631,6 @@ func TestFinalReadinessRequiresCompleteStepAfterWriterWhenTodoSeen(t *testing.T)
}
}
func TestFinalReadinessStopsAfterFirstBlock(t *testing.T) {
todoWrite, ok := tool.LookupBuiltin("todo_write")
if !ok {
t.Fatal("todo_write builtin not registered")
}
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "write_file", readOnly: false})
reg.Add(todoWrite)
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{
toolCallChunk("c1", "write_file", `{"path":"changed.go","content":"package main"}`),
toolCallChunk("c2", "todo_write", `{"todos":[{"content":"Edit code","status":"in_progress"}]}`),
{Type: provider.ChunkDone},
},
{{Type: provider.ChunkText, Text: "premature 1"}, {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "premature 2"}, {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "premature 3"}, {Type: provider.ChunkDone}},
}}
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
err := a.Run(context.Background(), "edit with todo and never sign off")
if err == nil {
t.Fatal("expected the first readiness block to stop the run")
}
if !strings.Contains(err.Error(), "final-answer readiness") {
t.Fatalf("error = %v, want final-answer readiness", err)
}
if prov.call != 2 {
t.Fatalf("provider calls = %d, want writer turn + one blocked final answer (no readiness retries)", prov.call)
}
}
func TestFinalReadinessPermissionLoopGuardAllowsBlockedFinal(t *testing.T) {
todoWrite, ok := tool.LookupBuiltin("todo_write")
if !ok {
t.Fatal("todo_write builtin not registered")
}
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "write_file", readOnly: false})
reg.Add(fakeTool{name: "bash", readOnly: false})
reg.Add(todoWrite)
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{
toolCallChunk("w1", "write_file", `{"path":"changed.go","content":"package main"}`),
toolCallChunk("t1", "todo_write", `{"todos":[{"content":"Edit code","status":"in_progress"}]}`),
{Type: provider.ChunkDone},
},
{toolCallChunk("b1", "bash", `{"command":"go test ./..."}`), {Type: provider.ChunkDone}},
{toolCallChunk("b2", "bash", `{"command":"git status --short"}`), {Type: provider.ChunkDone}},
{toolCallChunk("b3", "bash", `{"command":"ls -la"}`), {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "blocked by permission"}, {Type: provider.ChunkDone}},
}}
sink, notices := noticeRecorder()
a := New(prov, reg, NewSession(""), Options{
Gate: &stubGate{deny: map[string]bool{"bash": true}},
}, sink)
if err := a.Run(context.Background(), "edit with todo, then hit bash permission blocks"); err != nil {
t.Fatalf("Run: %v", err)
}
if prov.call != 5 {
t.Fatalf("provider calls = %d, want writer turn, three blocked bash calls, then final", prov.call)
}
if got := lastToolResult(a.sess.conversation, "bash"); !strings.Contains(got, "[loop guard]") {
t.Fatalf("last bash result = %q, want permission loop guard", got)
}
if got := toolResults(a.sess.conversation, "bash"); len(got) != stormBreakThreshold {
t.Fatalf("bash results = %d, want exactly %d blocked attempts", len(got), stormBreakThreshold)
}
if len(*notices) == 0 {
t.Fatal("loop guard should emit a user-facing notice")
}
}
// TestFinalReadinessPermissionLoopGuardAllowsBlockedFinalForBatch pins the
// multi-call variant: the guard text lands on the batch's FIRST result, so any
// detection keyed to the latest tool message misses it. The loop-guard pass is
// host state and must let the model report the blocker regardless of where in
// the batch the guard text sits.
func TestFinalReadinessPermissionLoopGuardAllowsBlockedFinalForBatch(t *testing.T) {
todoWrite, ok := tool.LookupBuiltin("todo_write")
if !ok {
t.Fatal("todo_write builtin not registered")
}
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "write_file", readOnly: false})
reg.Add(fakeTool{name: "bash", readOnly: false})
reg.Add(todoWrite)
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{
toolCallChunk("w1", "write_file", `{"path":"changed.go","content":"package main"}`),
toolCallChunk("t1", "todo_write", `{"todos":[{"content":"Edit code","status":"in_progress"}]}`),
{Type: provider.ChunkDone},
},
{
toolCallChunk("b1a", "bash", `{"command":"go test ./..."}`),
toolCallChunk("b1b", "bash", `{"command":"go vet ./..."}`),
{Type: provider.ChunkDone},
},
{
toolCallChunk("b2a", "bash", `{"command":"git status --short"}`),
toolCallChunk("b2b", "bash", `{"command":"git diff --stat"}`),
{Type: provider.ChunkDone},
},
{
toolCallChunk("b3a", "bash", `{"command":"ls -la"}`),
toolCallChunk("b3b", "bash", `{"command":"pwd"}`),
{Type: provider.ChunkDone},
},
{{Type: provider.ChunkText, Text: "blocked by permission"}, {Type: provider.ChunkDone}},
}}
a := New(prov, reg, NewSession(""), Options{
Gate: &stubGate{deny: map[string]bool{"bash": true}},
}, event.Discard)
if err := a.Run(context.Background(), "edit with todo, then hit batched bash permission blocks"); err != nil {
t.Fatalf("Run: %v", err)
}
if prov.call != 5 {
t.Fatalf("provider calls = %d, want writer turn, three blocked batches, then final", prov.call)
}
results := toolResults(a.sess.conversation, "bash")
if len(results) != 2*stormBreakThreshold {
t.Fatalf("bash results = %d, want %d blocked attempts across three batches", len(results), 2*stormBreakThreshold)
}
if !strings.Contains(results[len(results)-2], "[loop guard]") {
t.Fatalf("first result of the guarded batch should carry the loop guard, got: %q", results[len(results)-2])
}
if strings.Contains(results[len(results)-1], "[loop guard]") {
t.Fatalf("last result of the guarded batch should stay untouched (the pass must not depend on it), got: %q", results[len(results)-1])
}
}
func TestTodoWriteOnlyTurnMayEndWithIncompleteTodos(t *testing.T) {
todoWrite, ok := tool.LookupBuiltin("todo_write")
if !ok {
+2 -8
View File
@@ -70,13 +70,7 @@ func (a *Agent) executeBatch(ctx context.Context, turn *turnRuntime, calls []pro
durations := make([]int64, len(calls))
startedAt := make([]int64, len(calls))
completedStepInBatch := false
// Snapshot the receipt count before the batch runs: if a loop guard fires
// for this batch, successes recorded during it (a mixed batch where only one
// call was guard-blocked) must already count as progress against the pass.
receiptMark := 0
if a.task.ledger != nil {
receiptMark = a.task.ledger.Len()
}
receiptMark := a.ledgerMark()
// Full dispatches used the batch's initial file state. After a writer runs
// (even a failed one — disk may have mutated), refresh dependent writer
// previews. The first writer stays on the single-preview fast path.
@@ -362,7 +356,7 @@ func (a *Agent) executeBatch(ctx context.Context, turn *turnRuntime, calls []pro
a.svc.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: o.truncMsg})
}
}
a.applyBatchGuards(ctx, cancelled, calls, outcomes, results, receiptMark)
a.observeOutcomeShadow(cancelled, receiptMark)
images := make([][]string, len(calls))
executions := make([]*tool.ShellExecution, len(calls))
for i := range outcomes {
-19
View File
@@ -127,20 +127,6 @@ func (a *Agent) parseToolCall(ctx context.Context, plan *toolCallPlan) (toolOutc
errMsg: fmt.Sprintf("unknown tool %q", plan.call.Name),
}, true
}
if out, blocked := a.repeatedSuccessBlock(plan.call, t); blocked {
return toolOutcome{
output: out,
blocked: true,
errMsg: loopGuardBlockErrMsg,
}, true
}
if out, blocked := a.repeatedFailureBlock(ctx, plan.call, t); blocked {
return toolOutcome{
output: out,
blocked: true,
errMsg: loopGuardBlockErrMsg,
}, true
}
if out, blocked := a.staleAnchorEditBlock(plan.call); blocked {
return toolOutcome{
output: out,
@@ -828,7 +814,6 @@ func (a *Agent) finishToolExecution(ctx context.Context, plan *toolCallPlan) too
if !json.Valid([]byte(call.Arguments)) {
detail = strings.TrimRight(detail, "\n") + "\nThe arguments were not valid JSON. Re-emit them exactly per this schema:\n" + string(t.Schema())
}
a.recordRepeatFailure(call, t, err)
rawErr := fmt.Sprintf("error: %v\n%s", err, detail)
body, truncMsg := truncateToolOutputFor(rawErr, call.Name, call.ID)
out := toolOutcome{
@@ -840,10 +825,6 @@ func (a *Agent) finishToolExecution(ctx context.Context, plan *toolCallPlan) too
}
return out
}
if mutates {
a.clearRepeatFailuresAfterMutation(evidenceName, evidenceArgs, readOnly)
}
a.recordRepeatSuccess(call, t)
// A foreground `task` sub-agent just finished — its result is the final answer.
// (A backgrounded one returns a "Started…" string and stops later in a job, so
// it doesn't fire here.) SubagentStop lets a hook react to delegated work.
-8
View File
@@ -150,9 +150,6 @@ func (a *Agent) finalReadinessCheckFor() finalReadinessCheck {
}
if !hasWriter {
if len(missing) > 0 {
if a.loopGuardAllowsFinal() {
return out
}
out.reason = strings.Join(missing, "; ")
}
return out
@@ -210,17 +207,12 @@ func (a *Agent) finalReadinessCheckFor() finalReadinessCheck {
}
}
// Before the loop-guard escape on purpose: a criterion the model cannot
// prove must still be able to stop asking.
outstanding := a.outstandingPlanCriteria()
out.missingAcceptanceCriteria += len(outstanding)
missing = append(missing, outstanding...)
if len(missing) == 0 {
return out
}
if a.loopGuardAllowsFinal() {
return out
}
out.reason = strings.Join(missing, "; ")
return out
}
-77
View File
@@ -80,83 +80,6 @@ func TestFinalReadinessAllowsIncompleteTodosInPlanMode(t *testing.T) {
t.Fatalf("finalReadinessCheckFor() applies in plan mode: %+v", got)
}
}
func TestFinalReadinessCheckAuditsIncompleteTodos(t *testing.T) {
todo := evidence.Receipt{ToolName: "todo_write", Success: true, Todos: []evidence.TodoItem{{Content: "edit", Status: "in_progress"}}}
writer := evidence.Receipt{ToolName: "write_file", Success: true, Write: true, Paths: []string{"a.go"}}
a := &Agent{task: taskRuntime{ledger: readinessLedger(writer, todo)}}
got := a.finalReadinessCheckFor()
if !got.applies {
t.Fatalf("finalReadinessCheckFor() applies = false, want true")
}
if got.incompleteTodos != 1 {
t.Fatalf("incompleteTodos = %d, want 1", got.incompleteTodos)
}
if !strings.Contains(got.reason, "latest successful todo_write") {
t.Fatalf("reason = %q, want incomplete todo message", got.reason)
}
audit := got.audit(evidence.ReadinessBlocked, false)
if audit.IncompleteTodos != 1 {
t.Fatalf("audit.IncompleteTodos = %d, want 1", audit.IncompleteTodos)
}
}
func TestFinalReadinessAllowsFinalAfterLoopGuardedToolBlocker(t *testing.T) {
todo := evidence.Receipt{ToolName: "todo_write", Success: true, Todos: []evidence.TodoItem{{Content: "edit", Status: "in_progress"}}}
writer := evidence.Receipt{ToolName: "write_file", Success: true, Write: true, Paths: []string{"a.go"}}
ledger := readinessLedger(writer, todo)
a := &Agent{task: taskRuntime{ledger: ledger}}
a.armLoopGuardPass(ledger.Len())
got := a.finalReadinessCheckFor()
if !got.applies {
t.Fatalf("finalReadinessCheckFor() applies = false, want true audit after loop guard")
}
if got.reason != "" {
t.Fatalf("finalReadinessCheckFor() reason = %q, want loop guard to allow final blocker report", got.reason)
}
}
// TestFinalReadinessLoopGuardPassSurvivesBookkeeping proves the exact actions
// the loop guard recommends — ask, todo_write, complete_step — do not revoke
// the pass: the model must be able to record the blocker and then report it.
func TestFinalReadinessLoopGuardPassSurvivesBookkeeping(t *testing.T) {
todo := evidence.Receipt{ToolName: "todo_write", Success: true, Todos: []evidence.TodoItem{{Content: "edit", Status: "in_progress"}}}
writer := evidence.Receipt{ToolName: "write_file", Success: true, Write: true, Paths: []string{"a.go"}}
ledger := readinessLedger(writer, todo)
a := &Agent{task: taskRuntime{ledger: ledger}}
a.armLoopGuardPass(ledger.Len())
ledger.Record(evidence.Receipt{ToolName: "ask", Success: true})
ledger.Record(evidence.Receipt{ToolName: "todo_write", Success: true, Todos: []evidence.TodoItem{{Content: "edit", Status: "in_progress"}}})
ledger.Record(evidence.Receipt{ToolName: "complete_step", Success: true, Step: "edit"})
if got := a.finalReadinessCheckFor(); got.reason != "" {
t.Fatalf("finalReadinessCheckFor() reason = %q, want bookkeeping after the guard to keep the pass", got.reason)
}
}
// TestFinalReadinessLoopGuardPassRevokedByRealProgress proves a successful
// write or command receipt after the guard revokes the pass: receipts are
// obtainable again, so readiness resumes enforcing them.
func TestFinalReadinessLoopGuardPassRevokedByRealProgress(t *testing.T) {
todo := evidence.Receipt{ToolName: "todo_write", Success: true, Todos: []evidence.TodoItem{{Content: "edit", Status: "in_progress"}}}
writer := evidence.Receipt{ToolName: "write_file", Success: true, Write: true, Paths: []string{"a.go"}}
ledger := readinessLedger(writer, todo)
a := &Agent{task: taskRuntime{ledger: ledger}}
a.armLoopGuardPass(ledger.Len())
ledger.Record(evidence.Receipt{ToolName: "bash", Success: true, Command: "go test ./..."})
if got := a.finalReadinessCheckFor(); got.reason == "" {
t.Fatal("finalReadinessCheckFor() reason empty, want real progress after the guard to revoke the pass")
}
}
// TestFinalReadinessIgnoresLoopGuardQuotedInToolOutput proves the pass is host
// state, not message text: a tool result that merely quotes "[loop guard]"
// (a grep over this repo, a pasted transcript) must not unlock readiness.
func TestFinalReadinessIgnoresLoopGuardQuotedInToolOutput(t *testing.T) {
todo := evidence.Receipt{ToolName: "todo_write", Success: true, Todos: []evidence.TodoItem{{Content: "edit", Status: "in_progress"}}}
writer := evidence.Receipt{ToolName: "write_file", Success: true, Write: true, Paths: []string{"a.go"}}
-66
View File
@@ -31,72 +31,6 @@ func TestGoalRunHasNoDefaultModelRoundCeiling(t *testing.T) {
t.Fatalf("provider calls = %d, want 101 tool rounds plus final", prov.CallCount())
}
}
func TestExplicitMaxStepsStillAppliesToGoal(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "read_file", readOnly: true})
prov := testutil.NewMock("m",
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "r1", Name: "read_file", Arguments: `{"path":"a"}`}}},
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "r2", Name: "read_file", Arguments: `{"path":"b"}`}}},
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "r3", Name: "read_file", Arguments: `{"path":"c"}`}}},
testutil.Turn{Text: "Done."},
)
a := New(prov, reg, NewSession(""), Options{MaxSteps: 3}, event.Discard)
ctx := WithDeliveryExecutionScope(context.Background(), DeliveryExecutionScope{ID: "goal-1", TaskText: "work"})
err := a.Run(ctx, "work")
info, ok := InspectRunPause(err)
if !ok || info.Kind != "max_steps" || info.HostOwned || info.Limit != 3 {
t.Fatalf("explicit MaxSteps pause = %+v ok=%v err=%v", info, ok, err)
}
if prov.CallCount() != 4 {
t.Fatalf("provider calls = %d, want explicit three rounds plus summary", prov.CallCount())
}
}
func TestGoalSameFailureRedirectsWithoutPausing(t *testing.T) {
turns := []testutil.Turn{
{ToolCalls: []provider.ToolCall{{ID: "x1", Name: "missing_tool", Arguments: `{}`}}},
{ToolCalls: []provider.ToolCall{{ID: "x2", Name: "missing_tool", Arguments: `{}`}}},
{ToolCalls: []provider.ToolCall{{ID: "x3", Name: "missing_tool", Arguments: `{}`}}},
{Text: "The same host failure repeated; a different approach is required."},
}
prov := testutil.NewMock("m", turns...)
a := New(prov, tool.NewRegistry(), NewSession(""), Options{}, event.Discard)
ctx := WithDeliveryExecutionScope(context.Background(), DeliveryExecutionScope{ID: "goal-1", TaskText: "finish"})
err := a.Run(ctx, "work")
if err != nil {
t.Fatalf("Goal structural guard paused the run: %v", err)
}
if prov.CallCount() != stormBreakThreshold+1 {
t.Fatalf("provider calls = %d, want threshold plus one summary", prov.CallCount())
}
ordinary := testutil.NewMock("m", turns...)
plain := New(ordinary, tool.NewRegistry(), NewSession(""), Options{}, event.Discard)
if err := plain.Run(context.Background(), "work"); err != nil {
t.Fatalf("ordinary mode keeps the structural guard advisory: %v", err)
}
}
func TestGoalZeroEvidenceRedirectsAndContinues(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "read_file", readOnly: true})
turns := make([]testutil.Turn, 0, progressStopStreak+2)
for i := range progressStopStreak + 1 {
turns = append(turns, testutil.Turn{ToolCalls: []provider.ToolCall{{
ID: "same-" + string(rune('a'+i)), Name: "read_file", Arguments: `{"path":"same"}`,
}}})
}
turns = append(turns, testutil.Turn{Text: "The repeated read produced no new evidence."})
prov := testutil.NewMock("m", turns...)
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
ctx := WithDeliveryExecutionScope(context.Background(), DeliveryExecutionScope{ID: "goal-1", TaskText: "research"})
err := a.Run(ctx, "work")
if err != nil {
t.Fatalf("Goal progress guard paused the run: %v", err)
}
}
func TestUnboundedGoalParentLeavesChildUnbounded(t *testing.T) {
task := &TaskTool{}
if got := task.childMaxStepsForContext(context.Background(), 0); got != 0 {
+4 -50
View File
@@ -1,11 +1,9 @@
package agent
import (
"context"
"errors"
"fmt"
"reasonix/internal/event"
"reasonix/internal/provider"
)
@@ -34,11 +32,6 @@ func (a *Agent) HostProgressSignatures() []string {
return a.task.ledger.SuccessfulProgressSignaturesSince(0)
}
func (a *Agent) resetStructuralRunGuards() {
a.turn.stormSig, a.turn.stormCount, a.turn.blockedTurnStreak = "", 0, 0
a.turn.progress.reset()
}
func (a *Agent) stopUnexecutedBoundaryCalls(state *turnRuntime, calls []provider.ToolCall, usage *provider.Usage) (error, bool) {
switch {
case state.graceRound:
@@ -56,47 +49,8 @@ func (a *Agent) stopUnexecutedBoundaryCalls(state *turnRuntime, calls []provider
}
}
// 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(ctx context.Context, state *turnRuntime, receiptMark int) {
if a.planMode.Load() {
return
}
nextProgress, nextTracking := a.canonicalTodoProgress()
hostProgress := false
if a.task.ledger != nil {
for _, sig := range a.task.ledger.SuccessfulProgressSignaturesSince(receiptMark) {
if _, seen := state.seenTodoProgress[sig]; !seen {
hostProgress = true
state.seenTodoProgress[sig] = struct{}{}
}
}
}
switch {
case !nextTracking, !state.trackingTodoProgress || nextProgress > state.todoProgress || hostProgress:
state.todoStallRounds = 0
default:
state.todoStallRounds++
}
state.todoProgress, state.trackingTodoProgress = nextProgress, nextTracking
if state.todoStallRounds == todoProgressNudgeRounds {
a.sess.conversation.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(todoProgressNudgeMessage(state.todoStallRounds))})
a.svc.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
}
if _, goalScoped := DeliveryExecutionScopeFromContext(ctx); goalScoped {
rounds := state.todoStallRounds
state.todoStallRounds = 0
nudge := fmt.Sprintf("Host progress redirect: the current todo still has no new completion or unique host-observed work after %d tool-call rounds. Re-plan and continue: shrink the active step, switch tools or approach, delegate a focused sub-task, or use update_goal(blocked) only if a user or external condition is the sole blocker. Do not repeat the same calls.", rounds)
a.sess.conversation.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(nudge)})
a.svc.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeLoopGuard,
Text: loopGuardNoticeText(), Detail: fmt.Sprintf("the current Goal todo made no host-observed progress for %d rounds; resetting the intervention epoch and requiring a new plan", rounds)})
return
}
// resetTurnEvidence clears the ledger and the task budget together: a fresh
// ledger is what "a new task" means here, and a continuation keeps both.
func (a *Agent) resetTurnEvidence() {
a.task.restartLedger()
}
+21
View File
@@ -3,6 +3,7 @@ package agent
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
@@ -12,6 +13,26 @@ import (
"reasonix/internal/tool"
)
// okTool always succeeds; failTool always fails the same way. Together they
// stand in for a hook's happy and error paths without a real tool.
type okTool struct{ name string }
func (o okTool) Name() string { return o.name }
func (o okTool) Description() string { return "always succeeds" }
func (o okTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (o okTool) ReadOnly() bool { return true }
func (o okTool) Execute(context.Context, json.RawMessage) (string, error) { return "ok", nil }
type failTool struct{ name string }
func (f failTool) Name() string { return f.name }
func (f failTool) Description() string { return "always fails" }
func (f failTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (f failTool) ReadOnly() bool { return true }
func (f failTool) Execute(context.Context, json.RawMessage) (string, error) {
return "", errors.New("unexpected end of JSON input")
}
func TestToolHooksMayMutateWorkspaceUsesRunnerCapabilities(t *testing.T) {
if toolHooksMayMutateWorkspace(hook.NewRunner(nil, "/tmp", nil, nil)) {
t.Fatal("empty hook runner must not create a checkpoint coverage gap")
+29
View File
@@ -0,0 +1,29 @@
package agent
import (
"reasonix/internal/event"
"reasonix/internal/evidence"
)
// observeOutcomeShadow scores one round's receipts by outcome — information
// gathered, verifications run, state transitions, unverified change carried —
// and offers the sample to any sink collecting them. It is an instrument, not
// a guard: nothing reads the numbers back to decide what the turn may do.
func (a *Agent) observeOutcomeShadow(cancelled bool, receiptMark int) {
if cancelled || a.task.ledger == nil {
return
}
if a.task.outcome == nil {
a.task.outcome = evidence.NewOutcomeTracker()
}
event.RecordOutcomeProgress(a.svc.sink, a.task.outcome.ScoreRound(a.task.ledger.ReceiptsSince(receiptMark)))
}
// ledgerMark is the receipt count to score a round against, or zero before any
// ledger exists.
func (a *Agent) ledgerMark() int {
if a.task.ledger == nil {
return 0
}
return a.task.ledger.Len()
}
+9
View File
@@ -476,3 +476,12 @@ func TestCallContextMirrorsPlanModeOntoLeafKey(t *testing.T) {
t.Fatal("host-initiated wrapper lost the leaf plan-mode flag")
}
}
func mustBuiltinTool(t *testing.T, name string) tool.Tool {
t.Helper()
builtin, ok := tool.LookupBuiltin(name)
if !ok {
t.Fatalf("builtin %q is not registered", name)
}
return builtin
}
-189
View File
@@ -1,189 +0,0 @@
package agent
import (
"context"
"fmt"
"reasonix/internal/event"
"reasonix/internal/evidence"
"reasonix/internal/provider"
)
// The no-progress ladder is adaptive, not a fixed round count: rounds are
// judged by evidence gain (new reads, new results, mutations) and only
// consecutive zero-gain rounds escalate — nudge, then pivot, then stop.
const (
progressNudgeStreak = 2
progressPivotStreak = 4
progressStopStreak = 6
)
// progressGuard tracks consecutive tool rounds whose receipts produced no new
// evidence. State lives per user turn, alongside the ledger it observes.
type progressGuard struct {
tracker *evidence.ProgressTracker
streak int
}
func (g *progressGuard) reset() {
g.tracker = evidence.NewProgressTracker()
g.streak = 0
}
// observe folds one round's receipts and returns the current zero-gain streak.
func (g *progressGuard) observe(receipts []Receipt) int {
if g.tracker == nil {
g.reset()
}
if len(receipts) == 0 {
return g.streak
}
if g.tracker.ScoreRound(receipts) > 0 {
g.streak = 0
} else {
g.streak++
}
return g.streak
}
// Receipt aliases the evidence receipt for the guard's signature.
type Receipt = evidence.Receipt
// applyBatchGuards collects this round's signals — storm breaker (failure
// fixation), progress guard (zero-gain repetition) — and lets the arbiter
// deliver them as one tail. The outcome tracker observes the same receipts
// without influencing any verdict.
func (a *Agent) applyBatchGuards(ctx context.Context, cancelled bool, calls []provider.ToolCall, outcomes []toolOutcome, results []string, receiptMark int) {
if cancelled {
return
}
storm := a.applyStormBreaker(calls, outcomes, receiptMark)
_, goalScoped := DeliveryExecutionScopeFromContext(ctx)
progress := a.applyProgressGuard(outcomes, receiptMark, goalScoped)
a.observeOutcomeShadow(receiptMark)
a.applyInterventions(results, outcomes, storm, progress)
}
// resetTurnEvidence clears the ledger and both progress scorers together. The
// task budget resets with them: a fresh ledger is what "a new task" means here,
// and a continuation keeps both.
func (a *Agent) resetTurnEvidence() {
a.task.restartLedger()
a.turn.progress.reset()
a.turn.stormSig, a.turn.stormCount, a.turn.blockedTurnStreak = "", 0, 0
}
// observeOutcomeShadow scores the round's receipts by outcome and records the
// sample. It observes; it decides nothing. The scoring itself is incremental
// state the tracker must keep unbroken, so it runs even when no sink collects
// the samples — only the delivery is opt-in.
func (a *Agent) observeOutcomeShadow(receiptMark int) {
if a.task.ledger == nil {
return
}
if a.task.outcome == nil {
a.task.outcome = evidence.NewOutcomeTracker()
}
event.RecordOutcomeProgress(a.svc.sink, a.task.outcome.ScoreRound(a.task.ledger.ReceiptsSince(receiptMark)))
}
// applyProgressGuard escalates when consecutive rounds stop producing new
// evidence. At the stop tier it also arms the loop-guard pass so final
// readiness stands down and the model can deliver its answer instead of being
// sent back for more receipts.
func (a *Agent) applyProgressGuard(outcomes []toolOutcome, receiptMark int, goalScoped bool) intervention {
if a.task.ledger == nil || len(outcomes) == 0 {
return intervention{}
}
receipts := a.task.ledger.ReceiptsSince(receiptMark)
// Rounds where nothing succeeded are the storm breaker's jurisdiction
// (same-failure fixation); this guard owns the storm-blind case — rounds
// that keep SUCCEEDING without producing anything new.
anySuccess := false
for _, r := range receipts {
if r.Success {
anySuccess = true
break
}
}
if !anySuccess {
return intervention{}
}
streak := a.turn.progress.observe(receipts)
var guard, detail string
tier := verdictAdvise
warn := false
// Fire only when a threshold is crossed: repeating the injected guidance
// every round would inflate prompts (and can even tip compaction).
switch streak {
case progressStopStreak:
if goalScoped {
guard = fmt.Sprintf(
"[progress guard] %d tool rounds in a row produced no new evidence. Re-plan and continue: shrink the current step, switch tools or approach, delegate a focused sub-task, or report a real external/user blocker through update_goal. Do not repeat the same calls.",
streak)
detail = fmt.Sprintf("progress guard: %d zero-gain rounds — forcing a Goal re-plan", streak)
tier = verdictRedirect
// Start a fresh intervention epoch. The evidence tracker stays intact,
// so repeated work remains visible while a changed strategy can recover.
a.turn.progress.streak = 0
} else {
guard = fmt.Sprintf(
"[progress guard] %d tool rounds in a row produced no new evidence (no new files, results, or changes). Stop exploring: produce your final answer now, stating what was established and what remains unknown.",
streak)
detail = fmt.Sprintf("progress guard: %d zero-gain rounds — demanding a final answer", streak)
tier = verdictLand
warn = true
a.armLoopGuardPass(receiptMark)
}
case progressPivotStreak:
guard = fmt.Sprintf(
"[progress guard] still no new evidence after %d rounds. Change strategy now: take a different angle or tool, delegate a focused sub-task, or reduce the scope of what you are verifying.",
streak)
detail = fmt.Sprintf("progress guard: %d zero-gain rounds — forcing a strategy change", streak)
tier = verdictRedirect
case progressNudgeStreak:
guard = fmt.Sprintf(
"[progress guard] the last %d tool rounds repeated earlier reads or commands without new results. Narrow the investigation or adjust the plan before continuing.",
streak)
detail = fmt.Sprintf("progress guard: %d zero-gain rounds — nudging to narrow", streak)
default:
return intervention{}
}
level := event.LevelInfo
if warn {
level = event.LevelWarn
}
return intervention{
verdict: tier,
guidance: guard,
notice: noticeFor(event.NoticeCodeProgressGuard, level, progressGuardNoticeText(), detail),
}
}
func progressGuardNoticeText() string {
return "The assistant keeps repeating work without new evidence; asking it to change approach."
}
// armLoopGuardPass records that a loop guard fired this user turn.
// receiptMark is the evidence-ledger receipt count from just before the
// guarded batch ran, so a successful write or command receipt recorded after
// it counts as real progress and revokes the pass (see loopGuardAllowsFinal).
func (a *Agent) armLoopGuardPass(receiptMark int) {
a.turn.loopGuardArmed = true
a.turn.loopGuardReceiptMark = receiptMark
}
// loopGuardAllowsFinal reports whether final readiness should stand down: a
// guard fired this user turn and no successful write or command receipt has
// landed since. The missing receipts are exactly what the blocker prevents —
// demanding them would restart the loop the guard broke — while bookkeeping
// (ask, todo_write, complete_step) keeps the pass and real progress revokes it.
func (a *Agent) loopGuardAllowsFinal() bool {
if a == nil || !a.turn.loopGuardArmed {
return false
}
if a.task.ledger == nil {
return true
}
return !a.task.ledger.HasWriteOrCommandSince(a.turn.loopGuardReceiptMark)
}
-128
View File
@@ -1,128 +0,0 @@
package agent
import (
"context"
"encoding/json"
"strings"
"testing"
"reasonix/internal/event"
"reasonix/internal/evidence"
"reasonix/internal/provider"
"reasonix/internal/tool"
_ "reasonix/internal/tool/builtin"
)
func bashProgressReceipt(t *testing.T, command string, success bool) evidence.Receipt {
t.Helper()
args, err := json.Marshal(map[string]string{"command": command})
if err != nil {
t.Fatalf("marshal: %v", err)
}
return evidence.ReceiptFromToolCall("bash", args, success, false)
}
// runRound executes one read-only batch and returns its result texts.
func runRound(t *testing.T, a *Agent, path string) []string {
t.Helper()
batch := a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{
{ID: "c", Name: "read_probe", Arguments: `{"path":"` + path + `"}`},
})
return batch.results
}
func TestProgressGuardEscalatesOnZeroGainRounds(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "read_probe", readOnly: true})
a := New(nil, reg, NewSession(""), Options{}, event.Discard)
a.turn.progress.reset()
if got := runRound(t, a, "same.go"); strings.Contains(got[0], "[progress guard]") {
t.Fatalf("round 1 (new read, +1 gain) must not trip the guard: %q", got[0])
}
// Every later round re-reads the same path: zero gain, streak +1 per round.
if got := runRound(t, a, "same.go"); strings.Contains(got[0], "[progress guard]") {
t.Fatalf("one zero-gain round must not trip the guard yet: %q", got[0])
}
got := runRound(t, a, "same.go")
if !strings.Contains(got[0], "[progress guard]") || !strings.Contains(got[0], "Narrow the investigation") {
t.Fatalf("streak %d must nudge: %q", progressNudgeStreak, got[0])
}
runRound(t, a, "same.go")
got = runRound(t, a, "same.go")
if !strings.Contains(got[0], "Change strategy now") {
t.Fatalf("streak %d must force a pivot: %q", progressPivotStreak, got[0])
}
runRound(t, a, "same.go")
got = runRound(t, a, "same.go")
if !strings.Contains(got[0], "produce your final answer now") {
t.Fatalf("streak %d must demand the final answer: %q", progressStopStreak, got[0])
}
if !a.turn.loopGuardArmed {
t.Fatal("stop tier must arm the loop-guard pass so readiness stands down")
}
// Past the stop tier the loop-guard pass carries the pressure; repeating
// the injected text every round would only inflate prompts.
got = runRound(t, a, "same.go")
if strings.Contains(got[0], "[progress guard]") {
t.Fatalf("thresholds fire once, not every round: %q", got[0])
}
if !a.turn.loopGuardArmed {
t.Fatal("loop-guard pass must remain armed past the stop tier")
}
}
type outcomeSampleSink struct {
samples []evidence.OutcomeSample
}
func (s *outcomeSampleSink) Emit(event.Event) {}
func (s *outcomeSampleSink) RecordOutcomeProgress(sample evidence.OutcomeSample) {
s.samples = append(s.samples, sample)
}
func TestOutcomeShadowRecordsEveryRoundWithoutTouchingGuards(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "read_probe", readOnly: true})
sink := &outcomeSampleSink{}
a := New(nil, reg, NewSession(""), Options{}, sink)
a.resetTurnEvidence()
first := runRound(t, a, "a.go")
second := runRound(t, a, "a.go")
if len(sink.samples) != 2 {
t.Fatalf("got %d shadow samples, want one per round", len(sink.samples))
}
if s := sink.samples[0]; s.Round != 1 || s.Exploration != 1 || s.Objective != 0 {
t.Fatalf("round 1 sample = %+v, want exploration 1 objective 0", s)
}
if s := sink.samples[1]; s.Round != 2 || s.Exploration != 0 || s.LegacyGain != 0 {
t.Fatalf("round 2 repeat sample = %+v, want all-zero with legacy gain 0", s)
}
// The shadow observes; the guard alone decides. Round texts stay untouched
// below the nudge threshold exactly as before.
if strings.Contains(first[0], "[progress guard]") || strings.Contains(second[0], "[progress guard]") {
t.Fatalf("shadow must not change guard behavior: %q / %q", first[0], second[0])
}
}
func TestProgressGuardResetsOnNewEvidence(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "read_probe", readOnly: true})
a := New(nil, reg, NewSession(""), Options{}, event.Discard)
a.turn.progress.reset()
runRound(t, a, "a.go")
runRound(t, a, "a.go")
runRound(t, a, "a.go")
if a.turn.progress.streak < progressNudgeStreak {
t.Fatalf("streak = %d, want >= %d before fresh evidence", a.turn.progress.streak, progressNudgeStreak)
}
// A successful bash command receipt is fresh evidence: streak resets.
a.task.ledger.Record(bashProgressReceipt(t, "go test ./pkg", true))
mark := a.task.ledger.Len() - 1
a.turn.progress.observe(a.task.ledger.ReceiptsSince(mark))
if a.turn.progress.streak != 0 {
t.Fatalf("fresh evidence must reset the streak, got %d", a.turn.progress.streak)
}
}
-211
View File
@@ -1,211 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"reasonix/internal/evidence"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
// repeatFailureBreakThreshold is how many identical write-like failures are
// allowed before refusing the next attempt. Reads do not renew this budget
// because they cannot make unchanged stale write arguments valid.
const repeatFailureBreakThreshold = 2
func (a *Agent) repeatedFailureBlock(ctx context.Context, call provider.ToolCall, t tool.Tool) (string, bool) {
sig, _, ok := a.repeatFailureSignature(call, t)
if !ok || a.task.repeatFailures == nil {
return "", false
}
record, ok := a.task.repeatFailures[sig]
if !ok || record.count < repeatFailureBreakThreshold {
return "", false
}
if repeatFailurePreviewRechecksState(call.Name, record.errClass) {
if previewer, ok := t.(tool.Previewer); ok {
_, err := previewer.Preview(ctx, json.RawMessage(call.Arguments))
if err == nil || repeatFailureErrorClass(call.Name, err) != record.errClass {
delete(a.task.repeatFailures, sig)
return "", false
}
}
}
if record.stateRecheck {
return fmt.Sprintf(
"blocked: [loop guard] %q has already failed %d times while the same write intent remained invalid with the same failure class. Re-reading alone cannot make the same stale anchor succeed. Rebuild the edit from the current file contents with a new old_string, use multi_edit for related changes, or explain the blocker in your final answer.",
call.Name, record.count), true
}
return fmt.Sprintf(
"blocked: [loop guard] %q has already failed %d times with the same write intent and failure class. Change the write conditions or use a different approach instead of retrying the same call, or explain the blocker in your final answer.",
call.Name, record.count), true
}
func (a *Agent) recordRepeatFailure(call provider.ToolCall, t tool.Tool, execErr error) {
sig, paths, ok := a.repeatFailureSignature(call, t)
if !ok {
return
}
if a.task.repeatFailures == nil {
a.task.repeatFailures = make(map[string]repeatFailureRecord)
}
errClass := repeatFailureErrorClass(call.Name, execErr)
record := a.task.repeatFailures[sig]
if record.errClass != errClass {
record = repeatFailureRecord{
errClass: errClass,
paths: paths,
stateRecheck: repeatFailurePreviewRechecksState(call.Name, errClass),
}
}
record.count++
a.task.repeatFailures[sig] = record
}
func (a *Agent) repeatFailureSignature(call provider.ToolCall, t tool.Tool) (string, []string, bool) {
if t.ReadOnly() {
return "", nil, false
}
var semantic any
switch call.Name {
case "edit_file":
var p struct {
Path string `json:"path"`
OldString string `json:"old_string"`
}
if err := json.Unmarshal([]byte(call.Arguments), &p); err != nil {
return "", nil, false
}
paths := a.normalizeRepeatFailurePaths([]string{p.Path})
if len(paths) > 0 {
p.Path = paths[0]
}
semantic = p
case "multi_edit":
var p struct {
Path string `json:"path"`
Edits []struct {
OldString string `json:"old_string"`
ReplaceAll bool `json:"replace_all,omitempty"`
} `json:"edits"`
}
if err := json.Unmarshal([]byte(call.Arguments), &p); err != nil {
return "", nil, false
}
paths := a.normalizeRepeatFailurePaths([]string{p.Path})
if len(paths) > 0 {
p.Path = paths[0]
}
semantic = p
default:
sig, ok := repeatSuccessSignature(call, t)
if !ok {
return "", nil, false
}
rec := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.Arguments), false, t.ReadOnly())
return sig, a.normalizeRepeatFailurePaths(rec.Paths), true
}
encoded, err := json.Marshal(semantic)
if err != nil {
return "", nil, false
}
rec := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.Arguments), false, t.ReadOnly())
return call.Name + "\x00" + string(encoded), a.normalizeRepeatFailurePaths(rec.Paths), true
}
func repeatFailureErrorClass(name string, execErr error) string {
if execErr == nil {
return ""
}
msg := firstLine(execErr.Error())
switch name {
case "edit_file", "multi_edit":
switch {
case strings.Contains(msg, "old_string not found"):
return "old_string_not_found"
case strings.Contains(msg, "old_string is not unique"):
return "old_string_not_unique"
}
}
return msg
}
func repeatFailurePreviewRechecksState(name, errClass string) bool {
switch name {
case "edit_file", "multi_edit":
return errClass == "old_string_not_found" ||
errClass == "old_string_not_unique"
default:
return false
}
}
func (a *Agent) clearRepeatFailuresAfterMutation(toolName string, args json.RawMessage, readOnly bool) {
if len(a.task.repeatFailures) == 0 {
return
}
rec := evidence.ReceiptFromToolCall(toolName, args, true, readOnly)
mutatedPaths := a.normalizeRepeatFailurePaths(rec.Paths)
if len(mutatedPaths) == 0 {
for sig, failure := range a.task.repeatFailures {
// Anchor errors have an exact, side-effect-free state check. Keep
// their history until Preview shows that the old anchor works again.
if !failure.stateRecheck {
delete(a.task.repeatFailures, sig)
}
}
return
}
for sig, failure := range a.task.repeatFailures {
// A different edit to the same file does not make this old anchor valid.
// repeatedFailureBlock rechecks the actual call through Preview.
if !failure.stateRecheck && repeatFailurePathsOverlap(failure.paths, mutatedPaths) {
delete(a.task.repeatFailures, sig)
}
}
}
func (a *Agent) normalizeRepeatFailurePaths(paths []string) []string {
if len(paths) == 0 {
return nil
}
normalized := make([]string, 0, len(paths))
seen := make(map[string]bool, len(paths))
for _, raw := range paths {
path := strings.TrimSpace(raw)
if path == "" {
continue
}
if !filepath.IsAbs(path) && a.writeWorkspaceRoot != "" {
path = filepath.Join(a.writeWorkspaceRoot, path)
} else if !filepath.IsAbs(path) {
if absolute, err := filepath.Abs(path); err == nil {
path = absolute
}
}
path = foldPathKey(filepath.Clean(path))
if seen[path] {
continue
}
seen[path] = true
normalized = append(normalized, path)
}
return normalized
}
func repeatFailurePathsOverlap(left, right []string) bool {
for _, a := range left {
for _, b := range right {
if pathWithinFold(a, b) || pathWithinFold(b, a) {
return true
}
}
}
return false
}
-479
View File
@@ -1,479 +0,0 @@
package agent
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"reasonix/internal/diff"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
"reasonix/internal/tool/builtin"
)
type failingWriterTool struct {
name string
calls *int32
}
func (f failingWriterTool) Name() string { return f.name }
func (f failingWriterTool) Description() string { return "always fails to write" }
func (f failingWriterTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (f failingWriterTool) ReadOnly() bool { return false }
func (f failingWriterTool) Execute(context.Context, json.RawMessage) (string, error) {
if f.calls != nil {
atomic.AddInt32(f.calls, 1)
}
return "", errors.New("old_string not found in prompt.txt")
}
type stateAwareWriterTool struct {
name string
calls *int32
valid *atomic.Bool
}
type previewSuccessFailWriterTool struct {
name string
calls *int32
}
func (f previewSuccessFailWriterTool) Name() string { return f.name }
func (f previewSuccessFailWriterTool) Description() string {
return "previews successfully but cannot write"
}
func (f previewSuccessFailWriterTool) Schema() json.RawMessage {
return json.RawMessage(`{"type":"object"}`)
}
func (f previewSuccessFailWriterTool) ReadOnly() bool { return false }
func (f previewSuccessFailWriterTool) Execute(context.Context, json.RawMessage) (string, error) {
if f.calls != nil {
atomic.AddInt32(f.calls, 1)
}
return "", errors.New("write prompt.txt: permission denied")
}
func (f previewSuccessFailWriterTool) Preview(context.Context, json.RawMessage) (diff.Change, error) {
return diff.Change{Path: "prompt.txt"}, nil
}
func (f stateAwareWriterTool) Name() string { return f.name }
func (f stateAwareWriterTool) Description() string { return "fails until target state changes" }
func (f stateAwareWriterTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (f stateAwareWriterTool) ReadOnly() bool { return false }
func (f stateAwareWriterTool) Execute(context.Context, json.RawMessage) (string, error) {
if f.calls != nil {
atomic.AddInt32(f.calls, 1)
}
if f.valid != nil && f.valid.Load() {
return "edited prompt.txt", nil
}
return "", errors.New("old_string not found in prompt.txt")
}
func (f stateAwareWriterTool) Preview(context.Context, json.RawMessage) (diff.Change, error) {
if f.valid != nil && f.valid.Load() {
return diff.Change{Path: "prompt.txt"}, nil
}
return diff.Change{}, errors.New("old_string not found in prompt.txt")
}
func TestRepeatGuardBlocksRepeatedSuccessfulBashFileWrite(t *testing.T) {
var calls int32
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "bash", readOnly: false, calls: &calls})
args := `{"command":"python -c \"with open('prompt.txt', 'w') as f: f.write('hello')\""}`
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{toolCallChunk("c1", "bash", args), {Type: provider.ChunkDone}},
{toolCallChunk("c2", "bash", args), {Type: provider.ChunkDone}},
{toolCallChunk("c3", "bash", args), {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
}}
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
if err := a.Run(context.Background(), "update the prompt file"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := atomic.LoadInt32(&calls); got != 2 {
t.Fatalf("bash executed %d times, want 2 before the repeat guard blocks", got)
}
results := toolResults(a.sess.conversation, "bash")
if len(results) != 3 {
t.Fatalf("tool results = %d, want 3", len(results))
}
last := results[len(results)-1]
if !strings.Contains(last, "[loop guard]") || !strings.Contains(last, "edit_file") {
t.Fatalf("third repeated write should nudge the model to change tools, got %q", last)
}
}
func TestRepeatGuardAllowsRepeatedNonWritingBashCommand(t *testing.T) {
var calls int32
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "bash", readOnly: false, calls: &calls})
args := `{"command":"go test ./internal/agent"}`
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{toolCallChunk("c1", "bash", args), {Type: provider.ChunkDone}},
{toolCallChunk("c2", "bash", args), {Type: provider.ChunkDone}},
{toolCallChunk("c3", "bash", args), {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
}}
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
if err := a.Run(context.Background(), "verify repeatedly"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := atomic.LoadInt32(&calls); got != 3 {
t.Fatalf("bash executed %d times, want 3 for non-writing commands", got)
}
if last := lastToolResult(a.sess.conversation, "bash"); strings.Contains(last, "[loop guard]") {
t.Fatalf("non-writing bash should not trip the repeat guard, got %q", last)
}
}
func TestRepeatGuardBashWriteRedirectDetectionUsesAST(t *testing.T) {
tests := []struct {
name string
cmd string
want bool
}{
{
name: "stdout file redirect",
cmd: "printf hi > prompt.txt",
want: true,
},
{
name: "stderr file redirect",
cmd: "printf hi 2>err.log",
want: true,
},
{
name: "append redirect",
cmd: "printf hi >> prompt.txt",
want: true,
},
{
name: "combined redirect",
cmd: "printf hi &> prompt.txt",
want: true,
},
{
name: "read write redirect",
cmd: "cat <> prompt.txt",
want: true,
},
{
name: "null sink redirect",
cmd: "printf hi >/dev/null",
want: false,
},
{
name: "powershell null sink spelling",
cmd: "printf hi >$null",
want: false,
},
{
name: "windows nul sink spelling",
cmd: "printf hi >NUL",
want: false,
},
{
name: "fd duplication",
cmd: "printf hi 2>&1",
want: false,
},
{
name: "quoted redirect text",
cmd: `printf '%s\n' 'a > b'`,
want: false,
},
{
name: "heredoc body redirect text",
cmd: "cat <<'EOF'\n> prompt.txt\nEOF",
want: false,
},
{
name: "heredoc with file redirect",
cmd: "cat <<'EOF' > prompt.txt\nbody\nEOF",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isShellFileWriteCommand(tt.cmd); got != tt.want {
t.Fatalf("isShellFileWriteCommand(%q) = %v, want %v", tt.cmd, got, tt.want)
}
})
}
}
func TestRepeatGuardNormalizesStaticBashFields(t *testing.T) {
singleQuoted := normalizeShellCommand(`printf '%s\n' 'hello world'`)
doubleQuoted := normalizeShellCommand(`printf "%s\n" "hello world"`)
if singleQuoted != doubleQuoted {
t.Fatalf("normalized quote styles differ:\n single: %q\n double: %q", singleQuoted, doubleQuoted)
}
}
func TestRepeatGuardAllowsTwoRepeatedWriterSuccesses(t *testing.T) {
var calls int32
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "write_file", readOnly: false, calls: &calls})
args := `{"path":"prompt.txt","content":"hello"}`
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{toolCallChunk("c1", "write_file", args), {Type: provider.ChunkDone}},
{toolCallChunk("c2", "write_file", args), {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
}}
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
if err := a.Run(context.Background(), "write twice"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := atomic.LoadInt32(&calls); got != 2 {
t.Fatalf("writer executed %d times, want 2 before the guard threshold", got)
}
if last := lastToolResult(a.sess.conversation, "write_file"); strings.Contains(last, "[loop guard]") {
t.Fatalf("second repeated writer call should still be allowed, got %q", last)
}
}
func TestRepeatGuardBlocksStaleEditLoopAcrossSuccessfulReads(t *testing.T) {
var editCalls int32
var readCalls int32
reg := tool.NewRegistry()
reg.Add(failingWriterTool{name: "edit_file", calls: &editCalls})
reg.Add(fakeTool{name: "read_file", readOnly: true, calls: &readCalls})
editArgs1 := `{"path":"prompt.txt","old_string":"stale","new_string":"ready-v1"}`
editArgs2 := `{"path":"prompt.txt","old_string":"stale","new_string":"ready-v2"}`
editArgs3 := `{"path":"prompt.txt","old_string":"stale","new_string":"ready-v3"}`
readArgs := `{"path":"prompt.txt"}`
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{toolCallChunk("e1", "edit_file", editArgs1), {Type: provider.ChunkDone}},
{toolCallChunk("r1", "read_file", readArgs), {Type: provider.ChunkDone}},
{toolCallChunk("e2", "edit_file", editArgs2), {Type: provider.ChunkDone}},
{toolCallChunk("r2", "read_file", readArgs), {Type: provider.ChunkDone}},
{toolCallChunk("e3", "edit_file", editArgs3), {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "blocked"}, {Type: provider.ChunkDone}},
}}
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
if err := a.Run(context.Background(), "fix prompt.txt"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := atomic.LoadInt32(&editCalls); got != 2 {
t.Fatalf("edit_file executed %d times, want 2 before the repeat guard blocks", got)
}
if got := atomic.LoadInt32(&readCalls); got != 2 {
t.Fatalf("read_file executed %d times, want 2", got)
}
last := lastToolResult(a.sess.conversation, "edit_file")
for _, want := range []string{"[loop guard]", "already failed 2 times", "Re-reading alone"} {
if !strings.Contains(last, want) {
t.Fatalf("blocked stale edit result should mention %q, got %q", want, last)
}
}
}
func TestRepeatGuardRetainsStaleEditFailuresAcrossGoalScope(t *testing.T) {
var editCalls int32
reg := tool.NewRegistry()
reg.Add(failingWriterTool{name: "edit_file", calls: &editCalls})
editArgs := `{"path":"prompt.txt","old_string":"stale","new_string":"ready"}`
readArgs := `{"path":"prompt.txt"}`
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{toolCallChunk("e1", "edit_file", editArgs), {Type: provider.ChunkDone}},
{toolCallChunk("r1", "read_file", readArgs), {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "[goal:continue]"}, {Type: provider.ChunkDone}},
{toolCallChunk("e2", "edit_file", editArgs), {Type: provider.ChunkDone}},
{toolCallChunk("r2", "read_file", readArgs), {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "[goal:continue]"}, {Type: provider.ChunkDone}},
{toolCallChunk("e3", "edit_file", editArgs), {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "[goal:blocked:stale edit]"}, {Type: provider.ChunkDone}},
}}
reg.Add(fakeTool{name: "read_file", readOnly: true})
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
ctx := WithDeliveryExecutionScope(context.Background(), DeliveryExecutionScope{
ID: "goal-scope-1",
TaskText: "fix prompt.txt",
})
for i := range 3 {
if err := a.Run(ctx, "continue goal"); err != nil {
t.Fatalf("Run %d: %v", i+1, err)
}
}
if got := atomic.LoadInt32(&editCalls); got != 2 {
t.Fatalf("edit_file executed %d times across one goal scope, want 2", got)
}
if last := lastToolResult(a.sess.conversation, "edit_file"); !strings.Contains(last, "[loop guard]") {
t.Fatalf("third goal-scope edit should be blocked, got %q", last)
}
}
func TestRepeatGuardClearsOrdinaryWriteFailureAcrossGoalRuns(t *testing.T) {
var editCalls int32
reg := tool.NewRegistry()
reg.Add(previewSuccessFailWriterTool{name: "edit_file", calls: &editCalls})
editArgs := `{"path":"prompt.txt","old_string":"current","new_string":"ready"}`
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{toolCallChunk("e1", "edit_file", editArgs), {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "[goal:continue]"}, {Type: provider.ChunkDone}},
{toolCallChunk("e2", "edit_file", editArgs), {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "[goal:continue]"}, {Type: provider.ChunkDone}},
{toolCallChunk("e3", "edit_file", editArgs), {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "[goal:blocked:permission denied]"}, {Type: provider.ChunkDone}},
}}
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
ctx := WithDeliveryExecutionScope(context.Background(), DeliveryExecutionScope{
ID: "goal-scope-1",
TaskText: "fix prompt.txt",
})
for i := range 3 {
if err := a.Run(ctx, "continue goal"); err != nil {
t.Fatalf("Run %d: %v", i+1, err)
}
}
if got := atomic.LoadInt32(&editCalls); got != 3 {
t.Fatalf("edit_file executed %d times across Goal Runs, want ordinary write failure retried each Run", got)
}
}
func TestRepeatGuardDoesNotUsePreviewToClearWriteFailure(t *testing.T) {
var editCalls int32
reg := tool.NewRegistry()
reg.Add(previewSuccessFailWriterTool{name: "edit_file", calls: &editCalls})
a := New(nil, reg, NewSession(""), Options{}, event.Discard)
ctx := context.Background()
edit := provider.ToolCall{Name: "edit_file", Arguments: `{"path":"prompt.txt","old_string":"current","new_string":"ready"}`}
executeBatchOutputs(a, ctx, []provider.ToolCall{edit})
executeBatchOutputs(a, ctx, []provider.ToolCall{edit})
last := executeBatchOutputs(a, ctx, []provider.ToolCall{edit})[0]
if !strings.Contains(last, "[loop guard]") {
t.Fatalf("successful preview must not clear a repeated write failure, got %q", last)
}
for _, staleHint := range []string{"stale anchor", "Re-reading alone", "new old_string"} {
if strings.Contains(last, staleHint) {
t.Fatalf("ordinary write failure should not use stale-anchor guidance %q, got %q", staleHint, last)
}
}
if got := atomic.LoadInt32(&editCalls); got != 2 {
t.Fatalf("edit_file executed %d times, want write failure blocked before third execution", got)
}
}
func TestRepeatGuardKeepsStaleFailureAfterUnrelatedMutation(t *testing.T) {
var editCalls int32
reg := tool.NewRegistry()
reg.Add(failingWriterTool{name: "edit_file", calls: &editCalls})
reg.Add(fakeTool{name: "write_file", readOnly: false})
a := New(nil, reg, NewSession(""), Options{}, event.Discard)
ctx := context.Background()
edit := provider.ToolCall{Name: "edit_file", Arguments: `{"path":"prompt.txt","old_string":"stale","new_string":"ready"}`}
executeBatchOutputs(a, ctx, []provider.ToolCall{edit})
executeBatchOutputs(a, ctx, []provider.ToolCall{edit})
executeBatchOutputs(a, ctx, []provider.ToolCall{{
Name: "write_file", Arguments: `{"path":"other.txt","content":"unrelated"}`,
}})
last := executeBatchOutputs(a, ctx, []provider.ToolCall{edit})[0]
if !strings.Contains(last, "[loop guard]") {
t.Fatalf("unrelated mutation should not clear stale failure history, got %q", last)
}
if got := atomic.LoadInt32(&editCalls); got != 2 {
t.Fatalf("edit_file executed %d times, want unrelated mutation to preserve the guard", got)
}
}
func TestRepeatGuardKeepsStaleFailureAfterSameFileMutation(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "prompt.txt")
if err := os.WriteFile(path, []byte("status=ready\n"), 0o600); err != nil {
t.Fatal(err)
}
reg := tool.NewRegistry()
for _, tl := range (builtin.Workspace{Dir: dir}).Tools("edit_file") {
reg.Add(tl)
}
a := New(nil, reg, NewSession(""), Options{WriteWorkspaceRoot: dir}, event.Discard)
ctx := context.Background()
stale := provider.ToolCall{
Name: "edit_file",
Arguments: `{"path":"prompt.txt","old_string":"status=stale","new_string":"status=fixed"}`,
}
executeBatchOutputs(a, ctx, []provider.ToolCall{stale})
executeBatchOutputs(a, ctx, []provider.ToolCall{stale})
executeBatchOutputs(a, ctx, []provider.ToolCall{{
Name: "edit_file",
Arguments: `{"path":"prompt.txt","old_string":"status=ready","new_string":"status=done"}`,
}})
last := executeBatchOutputs(a, ctx, []provider.ToolCall{stale})[0]
if !strings.Contains(last, "[loop guard]") {
t.Fatalf("same-file mutation must not renew a still-stale anchor budget, got %q", last)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if got := string(data); got != "status=done\n" {
t.Fatalf("file content = %q, want successful unrelated edit preserved", got)
}
}
func TestRepeatGuardNormalizesFailureTargetPaths(t *testing.T) {
var editCalls int32
dir := t.TempDir()
reg := tool.NewRegistry()
reg.Add(failingWriterTool{name: "edit_file", calls: &editCalls})
a := New(nil, reg, NewSession(""), Options{WriteWorkspaceRoot: dir}, event.Discard)
ctx := context.Background()
args := []string{
`{"path":"prompt.txt","old_string":"stale","new_string":"ready-v1"}`,
`{"path":"./prompt.txt","old_string":"stale","new_string":"ready-v2"}`,
`{"path":` + string(mustJSON(t, filepath.Join(dir, "prompt.txt"))) + `,"old_string":"stale","new_string":"ready-v3"}`,
}
executeBatchOutputs(a, ctx, []provider.ToolCall{{Name: "edit_file", Arguments: args[0]}})
executeBatchOutputs(a, ctx, []provider.ToolCall{{Name: "edit_file", Arguments: args[1]}})
last := executeBatchOutputs(a, ctx, []provider.ToolCall{{Name: "edit_file", Arguments: args[2]}})[0]
if !strings.Contains(last, "[loop guard]") {
t.Fatalf("path aliases should share one repeated-failure signature, got %q", last)
}
if got := atomic.LoadInt32(&editCalls); got != 2 {
t.Fatalf("edit_file executed %d times, want absolute-path retry blocked", got)
}
}
func TestRepeatGuardAllowsRetryAfterExternalTargetChange(t *testing.T) {
var editCalls int32
var valid atomic.Bool
reg := tool.NewRegistry()
reg.Add(stateAwareWriterTool{name: "edit_file", calls: &editCalls, valid: &valid})
a := New(nil, reg, NewSession(""), Options{}, event.Discard)
ctx := context.Background()
edit := provider.ToolCall{Name: "edit_file", Arguments: `{"path":"prompt.txt","old_string":"stale","new_string":"ready"}`}
executeBatchOutputs(a, ctx, []provider.ToolCall{edit})
executeBatchOutputs(a, ctx, []provider.ToolCall{edit})
valid.Store(true)
last := executeBatchOutputs(a, ctx, []provider.ToolCall{edit})[0]
if strings.Contains(last, "[loop guard]") {
t.Fatalf("changed target state should allow the retry, got %q", last)
}
if got := atomic.LoadInt32(&editCalls); got != 3 {
t.Fatalf("edit_file executed %d times, want retry after external target change", got)
}
}
-14
View File
@@ -115,7 +115,6 @@ func (a *Agent) beginRunTurn(ctx context.Context, input string) (rawInput string
// values are computed below. Cross-turn state (checkpoint, scope, failure
// budgets) lives in taskRuntime and is reconciled there.
a.turn = turnRuntime{}
a.resetStructuralRunGuards()
scope, scoped := DeliveryExecutionScopeFromContext(ctx)
preserveEvidence := a.pending.preserveEvidence
// A run that starts with a pending readiness recovery (or an explicit
@@ -204,7 +203,6 @@ func (a *Agent) beginRunTurn(ctx context.Context, input string) (rawInput string
// transcript tail. Fold its bounded facts into this new user turn exactly
// once; the user's raw text remains the classifier source above.
providerInput = withInterruptedRecovery(providerInput, a.pendingInterruptedRecovery())
a.task.prepareScope(scoped, scope.ID)
a.svc.sink.Emit(event.Event{Kind: event.TurnStarted})
a.emitTurnPhase(event.TurnPhaseWorking)
input = a.withTurnPreferences(providerInput)
@@ -229,16 +227,9 @@ func (a *Agent) beginRunTurn(ctx context.Context, input string) (rawInput string
// opening a second object: one turn, one turnRuntime. The zero values the
// old literal spelled out are already there from the reset at the top.
state = &a.turn
state.seenTodoProgress = make(map[string]struct{})
state.executorHandoff = a.executorHandoffGuard && strings.Contains(input, executorHandoffMarker)
state.input = input
state.budget = runBudget{started: time.Now()}
state.todoProgress, state.trackingTodoProgress = a.canonicalTodoProgress()
if a.task.ledger != nil {
for _, sig := range a.task.ledger.SuccessfulProgressSignaturesSince(0) {
state.seenTodoProgress[sig] = struct{}{}
}
}
return rawInput, state
}
@@ -610,10 +601,6 @@ func (a *Agent) handleToolRound(ctx context.Context, state *turnRuntime, step in
return false, boundaryErr
}
receiptMark := 0
if a.task.ledger != nil {
receiptMark = a.task.ledger.Len()
}
batch := a.executeBatch(ctx, state, calls)
results, images := batch.results, batch.images
for i, call := range calls {
@@ -650,7 +637,6 @@ func (a *Agent) handleToolRound(ctx context.Context, state *turnRuntime, step in
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.sess.conversation.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(nudge)})
}
a.trackTodoProgress(ctx, state, receiptMark)
// The prompt only grows from here; compact before the next turn so it
// stays within the model's window.
-95
View File
@@ -3,13 +3,6 @@ package agent
import (
"context"
"encoding/json"
"fmt"
"sync/atomic"
"testing"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
// readProbe is a read-only tool taking a path, so its receipt is classified the
@@ -26,91 +19,3 @@ func (readProbe) ReadOnly() bool { return true }
func (readProbe) Execute(context.Context, json.RawMessage) (string, error) {
return "package main\n\nfunc main() {}\n", nil
}
// wanderingProvider replays the reported runaway: every round restates the same
// intent and reads one more file. Nothing fails, so the storm breaker stays
// quiet; nothing repeats, so every round scores as new evidence. sameArg swaps
// in one fixed path to isolate novelty as the discriminator.
type wanderingProvider struct {
rounds atomic.Int32
max int32
sameArg bool
}
func (p *wanderingProvider) Name() string { return "wandering" }
func (p *wanderingProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
round := p.rounds.Add(1)
ch := make(chan provider.Chunk, 4)
if round > p.max {
ch <- provider.Chunk{Type: provider.ChunkText, Text: "Done."}
ch <- provider.Chunk{Type: provider.ChunkDone}
close(ch)
return ch, nil
}
path := fmt.Sprintf("internal/pkg%d/file.go", round)
if p.sameArg {
path = "HANDOVER.md"
}
ch <- provider.Chunk{Type: provider.ChunkText, Text: "收到。先收集当前真实状态,然后彻底重写 HANDOVER.md。"}
ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{
ID: fmt.Sprintf("call-%d", round),
Name: "read_file",
Arguments: fmt.Sprintf(`{"path":%q}`, path),
}}
ch <- provider.Chunk{Type: provider.ChunkDone}
close(ch)
return ch, nil
}
func runRunaway(t *testing.T, sameArg bool, maxRounds int32) (guardFired bool, rounds int32) {
t.Helper()
var fired, maintained atomic.Bool
sink := event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice && e.Code == event.NoticeCodeProgressGuard {
fired.Store(true)
}
if e.Kind == event.ContextMaintenanceEvent {
maintained.Store(true)
}
})
t.Cleanup(func() {
// The repro must not lean on context maintenance: this shape runs away
// on its own, so a fix to trimming alone cannot bound it.
if maintained.Load() {
t.Error("context maintenance ran; the repro is no longer isolated from trimming")
}
})
reg := tool.NewRegistry()
reg.Add(readProbe{})
prov := &wanderingProvider{max: maxRounds, sameArg: sameArg}
// MaxSteps 0 is the shipped default, not a test convenience: [agent].max_steps
// was retired and is normalized to zero on load.
a := New(prov, reg, NewSession("sys"), Options{MaxSteps: 0}, sink)
if err := a.Run(context.Background(), "collect the real state, then rewrite HANDOVER.md"); err != nil {
t.Fatalf("Run: %v", err)
}
return fired.Load(), prov.rounds.Load()
}
// The reported runaway: a read-only turn restating one intent while touching a
// new path each round. It used to score positive gain forever, so the ladder
// could never climb; reading is progress only while it leads somewhere.
func TestReadOnlyWanderingTripsTheProgressGuard(t *testing.T) {
guardFired, ran := runRunaway(t, false, 40)
if !guardFired {
t.Fatalf("wandering for %d rounds never reached the no-progress ladder", ran)
}
}
// The contrast that names the discriminator. The identical loop reading one
// fixed path is caught within a few rounds: novelty, not usefulness, is what
// the guard measures — and it is the whole difference between a runaway the
// host stops early and one that burns four hours.
func TestRepeatedReadTripsTheProgressGuard(t *testing.T) {
guardFired, ran := runRunaway(t, true, 40)
if !guardFired {
t.Fatalf("repeating one read for %d rounds never fired the guard", ran)
}
}
-163
View File
@@ -1,163 +0,0 @@
package agent
import (
"fmt"
"strings"
"reasonix/internal/event"
"reasonix/internal/provider"
)
// stormBreakThreshold is how many times in a row the same tool may fail the same
// way before the loop stops echoing the raw error back and instead returns a
// directive to change approach. Two natural self-corrections are healthy; the
// third identical failure is a death-spiral — the dominant case being a tool call
// whose arguments are truncated at the output-token ceiling, which the model then
// re-emits (re-worded but still over-long), truncating the same way again.
const stormBreakThreshold = 3
// repeatSuccessBreakThreshold is how many identical write-like successes the
// agent allows before refusing another copy in the same user turn. Two gives the
// model room for a natural self-correction; the third repeat is usually a
// no-op/write loop and should be redirected to a different tool or final answer.
const repeatSuccessBreakThreshold = 2
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 is the second Goal-only adaptive checkpoint. It resets
// the intervention epoch and asks for a new plan without ending the run.
maxTodoStallRounds = 16
)
func todoProgressNudgeMessage(rounds int) string {
return fmt.Sprintf("Host progress check: the current todo has produced no new completion, unique read, command, or mutation for %d tool-call rounds. Reassess before using more tools: sign off the current item if it is done, narrow the remaining work without replacing the active item, or explain/ask about a real blocker. Do not repeat reads, commands, or writes just to reset this guard.", rounds)
}
// loopGuardBlockErrMsg is the errMsg carried by a repeat-success loop-guard
// block. applyStormBreaker matches it to arm the final-readiness loop-guard
// pass, since that guard also invites the model to report the blocker.
const loopGuardBlockErrMsg = "blocked by loop guard"
// applyStormBreaker detects a run of zero-progress turns and, past the
// threshold, rewrites the model-facing result (results[0]) into a directive to
// change approach. Two detectors, because a stuck model varies its retries two
// ways. The signature detector keys on each call's (tool, error/blocker) — not
// its args — since a stuck model reworks the arguments cosmetically while
// hitting the same host refusal or failure (see the stormSig field doc). The
// streak detector counts consecutive turns in which every call was blocked,
// regardless of shape: rotating tools, reordering a batch, or a blocker whose
// text varies per attempt escapes the signature but is still zero progress —
// only a host refusal (not a plain error) proves that, so the streak requires
// blocked outcomes. Any success resets both. When a guard fires — or when a
// call in the batch was already blocked by the per-call repeat-success guard —
// the final-readiness loop-guard pass is armed so the model may report the
// blocker (see loopGuardAllowsFinal). The hard maxSteps guard remains the
// ultimate backstop; this just keeps the loop from burning that whole budget
// bouncing off the same host refusals.
func (a *Agent) applyStormBreaker(calls []provider.ToolCall, outcomes []toolOutcome, receiptMark int) intervention {
allBlocked := len(outcomes) > 0
for _, outcome := range outcomes {
if !outcome.blocked {
allBlocked = false
break
}
}
if allBlocked {
a.turn.blockedTurnStreak++
} else {
a.turn.blockedTurnStreak = 0
}
for _, outcome := range outcomes {
if outcome.blocked && outcome.errMsg == loopGuardBlockErrMsg {
a.armLoopGuardPass(receiptMark)
break
}
}
sig, ok := batchStormSignature(calls, outcomes)
switch {
case !ok:
a.turn.stormSig, a.turn.stormCount = "", 0
case sig != a.turn.stormSig:
a.turn.stormSig, a.turn.stormCount = sig, 1
default:
a.turn.stormCount++
}
stormHit := ok && a.turn.stormCount >= stormBreakThreshold
streakHit := allBlocked && a.turn.blockedTurnStreak >= stormBreakThreshold
if !stormHit && !streakHit {
return intervention{}
}
const blockedAdvice = "Change approach: do not keep retrying a blocked tool by changing the tool, command, or arguments. Respect the permission, plan-mode, hook, or loop-guard blocker; use an already-allowed tool, ask the user for the specific approval or choice if appropriate, or explain the blocker in your final answer."
var guard, detail string
if stormHit {
subject := fmt.Sprintf("%q", calls[0].Name)
short := calls[0].Name
if len(calls) > 1 {
subject = fmt.Sprintf("this batch of %d tool calls", len(calls))
short = fmt.Sprintf("a batch of %d calls", len(calls))
}
anyBlocked := false
for _, outcome := range outcomes {
if outcome.blocked {
anyBlocked = true
break
}
}
action := "failed"
advice := "Change approach: if an argument is being truncated, write less in one call and split the work into several smaller calls; otherwise fix the arguments, use a different tool, or explain the blocker in your final answer."
if anyBlocked {
action = "been blocked or failed"
advice = blockedAdvice
}
guard = fmt.Sprintf(
"[loop guard] %s has now %s %d times in a row with the same host response. Re-sending it — even with the wording changed — will not help: the calls keep hitting the same outcome. %s",
subject, action, a.turn.stormCount, advice)
detail = fmt.Sprintf(
"loop guard: %s hit the same host response %d× — nudging the model to change approach",
short, a.turn.stormCount)
} else {
guard = fmt.Sprintf(
"[loop guard] every tool call in the last %d turns has been blocked by the host (permission, plan mode, hook, or loop guard). Switching tools, reordering calls, or rewording arguments will not help while the blockers stand. %s",
a.turn.blockedTurnStreak, blockedAdvice)
detail = fmt.Sprintf(
"loop guard: every tool call blocked %d turns in a row — nudging the model to change approach",
a.turn.blockedTurnStreak)
}
a.armLoopGuardPass(receiptMark)
return intervention{
verdict: verdictRedirect,
guidance: guard,
notice: noticeFor(event.NoticeCodeLoopGuard, event.LevelInfo, loopGuardNoticeText(), detail),
}
}
func loopGuardNoticeText() string {
return "The assistant is not making progress; asking it to change approach."
}
// batchStormSignature returns a per-turn fixation signature — each call's
// (name, error/blocker) in order — and ok=true only when every call errored or
// was blocked. ok=false (any success) means the turn made progress, so the
// caller resets the counter. Keying on the host response rather than the args is
// deliberate: a stuck model reworks the arguments while hitting the same
// response, so identical-args matching would miss the loop.
func batchStormSignature(calls []provider.ToolCall, outcomes []toolOutcome) (string, bool) {
if len(calls) == 0 {
return "", false
}
var sb strings.Builder
for i := range calls {
if outcomes[i].errMsg == "" {
return "", false
}
sb.WriteString(calls[i].Name)
sb.WriteByte(0)
sb.WriteString(outcomes[i].errMsg)
sb.WriteByte(0)
}
return sb.String(), true
}
-292
View File
@@ -1,292 +0,0 @@
package agent
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
// failTool always errors with the same message regardless of its arguments,
// standing in for a call the model keeps re-emitting (e.g. arguments truncated at
// the output-token ceiling, which fail to parse the same way every time even as
// the model re-words the payload).
type failTool struct{ name string }
func (f failTool) Name() string { return f.name }
func (f failTool) Description() string { return "always fails" }
func (f failTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (f failTool) ReadOnly() bool { return true }
func (f failTool) Execute(context.Context, json.RawMessage) (string, error) {
return "", errors.New("unexpected end of JSON input")
}
// okTool always succeeds — a turn of real progress that breaks a failing run.
type okTool struct{ name string }
func (o okTool) Name() string { return o.name }
func (o okTool) Description() string { return "always succeeds" }
func (o okTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (o okTool) ReadOnly() bool { return true }
func (o okTool) Execute(context.Context, json.RawMessage) (string, error) { return "ok", nil }
func noticeRecorder() (event.Sink, *[]string) {
var notices []string
sink := event.FuncSink(func(e event.Event) {
// Storm tests assert on the storm breaker's own notices; the adaptive
// progress guard has its own code and cadence (progress_guard_test.go).
if e.Kind == event.Notice && e.Code == event.NoticeCodeLoopGuard {
notices = append(notices, e.Text)
}
})
return sink, &notices
}
// TestStormBreakerEscalatesRepeatedFailure: once the same tool has failed the
// same way stormBreakThreshold times in a row, the model-facing result must carry
// the loop-guard directive (not just the raw error again), and the user must get
// a notice. The arguments DIFFER on every call — mirroring the live failure
// mode where a stuck model re-words the payload — to prove detection keys on the
// error, not the bytes.
func TestStormBreakerEscalatesRepeatedFailure(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(failTool{name: "write_file"})
sink, notices := noticeRecorder()
a := New(nil, reg, NewSession(""), Options{}, sink)
args := []string{`{"content":"Mountains are`, `{"path":"n.txt","content":"Peaks rise`, `{}`}
var last string
for i := range stormBreakThreshold {
call := provider.ToolCall{Name: "write_file", Arguments: args[i]}
last = executeBatchOutputs(a, context.Background(), []provider.ToolCall{call})[0]
}
if !strings.Contains(last, "[loop guard]") {
t.Fatalf("after %d same-error failures the result should carry the loop guard, got: %q", stormBreakThreshold, last)
}
if !strings.Contains(last, "write_file") {
t.Errorf("loop-guard text should name the offending tool, got: %q", last)
}
if !strings.Contains(last, "unexpected end of JSON input") {
t.Errorf("loop-guard result should still preserve the original error, got: %q", last)
}
if len(*notices) == 0 {
t.Errorf("loop guard should emit a notice to the user")
}
}
// TestStormBreakerEscalatesRepeatedBlockedPermission covers the readiness
// recovery failure mode where the model keeps changing bash commands after the
// host returns the same permission denial. Blocked calls used to reset the storm
// counter, so this loop could churn approval prompts without ever changing
// approach.
func TestStormBreakerEscalatesRepeatedBlockedPermission(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "bash", readOnly: false})
sink, notices := noticeRecorder()
a := New(nil, reg, NewSession(""), Options{
Gate: &stubGate{deny: map[string]bool{"bash": true}},
}, sink)
args := []string{
`{"command":"go test ./..."}`,
`{"command":"git status --short"}`,
`{"command":"ls -la"}`,
}
var last string
for i := range stormBreakThreshold {
call := provider.ToolCall{Name: "bash", Arguments: args[i]}
last = executeBatchOutputs(a, context.Background(), []provider.ToolCall{call})[0]
}
if !strings.Contains(last, "[loop guard]") {
t.Fatalf("after %d same permission blocks the result should carry the loop guard, got: %q", stormBreakThreshold, last)
}
if !strings.Contains(last, "blocked") || !strings.Contains(last, "permission") {
t.Fatalf("permission loop guard should preserve blocked context, got: %q", last)
}
if len(*notices) == 0 {
t.Errorf("loop guard should emit a notice to the user")
}
}
// TestStormBreakerEscalatesAlternatingBlockedShapes: rotating between two
// blocked tools defeats the signature detector (each turn resets the count),
// but every turn is still a host refusal with zero progress. The blocked-turn
// streak must trip the guard anyway.
func TestStormBreakerEscalatesAlternatingBlockedShapes(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "bash", readOnly: false})
reg.Add(fakeTool{name: "web_fetch", readOnly: false})
sink, notices := noticeRecorder()
a := New(nil, reg, NewSession(""), Options{
Gate: &stubGate{deny: map[string]bool{"bash": true, "web_fetch": true}},
}, sink)
calls := []provider.ToolCall{
{Name: "bash", Arguments: `{"command":"go test ./..."}`},
{Name: "web_fetch", Arguments: `{"url":"https://example.com"}`},
{Name: "bash", Arguments: `{"command":"ls"}`},
}
var last string
for _, call := range calls {
last = executeBatchOutputs(a, context.Background(), []provider.ToolCall{call})[0]
}
if !strings.Contains(last, "[loop guard]") {
t.Fatalf("after %d all-blocked turns the guard should fire despite alternating tools, got: %q", stormBreakThreshold, last)
}
if !a.turn.loopGuardArmed {
t.Fatal("streak guard should arm the final-readiness loop-guard pass")
}
if len(*notices) == 0 {
t.Errorf("streak loop guard should emit a notice to the user")
}
}
// TestStormBreakerBlockedStreakResetBySuccess: a turn that makes real progress
// proves the model is not stuck, so the blocked-turn streak must start over.
func TestStormBreakerBlockedStreakResetBySuccess(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "bash", readOnly: false})
reg.Add(fakeTool{name: "read_file", readOnly: true})
a := New(nil, reg, NewSession(""), Options{
Gate: &stubGate{deny: map[string]bool{"bash": true}},
}, event.Discard)
calls := []provider.ToolCall{
{Name: "bash", Arguments: `{"command":"go test ./..."}`},
{Name: "bash", Arguments: `{"command":"ls"}`},
{Name: "read_file", Arguments: `{"path":"a.go"}`},
{Name: "bash", Arguments: `{"command":"pwd"}`},
}
var last string
for _, call := range calls {
last = executeBatchOutputs(a, context.Background(), []provider.ToolCall{call})[0]
}
if strings.Contains(last, "[loop guard]") {
t.Fatalf("a successful turn should reset the blocked streak, got: %q", last)
}
if a.turn.blockedTurnStreak != 1 {
t.Fatalf("blockedTurnStreak = %d, want 1 after success reset plus one block", a.turn.blockedTurnStreak)
}
}
// TestStormBreakerEscalatesRepeatedBatch: a multi-call batch that fails the same
// way every round is just as much a death-spiral as a single call — once the whole
// batch repeats stormBreakThreshold times, the guard must fire and name the batch.
func TestStormBreakerEscalatesRepeatedBatch(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(failTool{name: "write_a"})
reg.Add(failTool{name: "write_b"})
sink, notices := noticeRecorder()
a := New(nil, reg, NewSession(""), Options{}, sink)
batch := []provider.ToolCall{
{Name: "write_a", Arguments: `{"content":"x`},
{Name: "write_b", Arguments: `{"content":"y`},
}
var first string
for range stormBreakThreshold {
first = executeBatchOutputs(a, context.Background(), batch)[0]
}
if !strings.Contains(first, "[loop guard]") {
t.Fatalf("a repeated all-failing batch should trip the guard, got: %q", first)
}
if !strings.Contains(first, "batch of 2") {
t.Errorf("guard should name the repeated batch, got: %q", first)
}
if len(*notices) == 0 {
t.Errorf("loop guard should emit a notice for a repeated batch")
}
}
// TestStormBreakerBatchResetsOnPartialSuccess: a batch where even one call
// succeeds is progress, not a fixation — the guard must never fire, however many
// times the batch repeats.
func TestStormBreakerBatchResetsOnPartialSuccess(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(failTool{name: "write_file"})
reg.Add(okTool{name: "read_file"})
sink, notices := noticeRecorder()
a := New(nil, reg, NewSession(""), Options{}, sink)
batch := []provider.ToolCall{
{Name: "write_file", Arguments: `{"content":"x`},
{Name: "read_file", Arguments: `{"path":"x"}`},
}
var first string
for range stormBreakThreshold + 2 {
first = executeBatchOutputs(a, context.Background(), batch)[0]
}
if strings.Contains(first, "[loop guard]") {
t.Fatalf("a batch with a succeeding call should never trip the guard, got: %q", first)
}
if len(*notices) != 0 {
t.Errorf("no notice expected when part of the batch succeeds, got %v", *notices)
}
}
// TestStormBreakerSilentBelowThreshold: the first few self-corrections are
// healthy — the guard must not fire before the threshold.
func TestStormBreakerSilentBelowThreshold(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(failTool{name: "write_file"})
sink, notices := noticeRecorder()
a := New(nil, reg, NewSession(""), Options{}, sink)
call := provider.ToolCall{Name: "write_file", Arguments: `{"content":"x`}
var last string
for range stormBreakThreshold - 1 {
last = executeBatchOutputs(a, context.Background(), []provider.ToolCall{call})[0]
}
if strings.Contains(last, "[loop guard]") {
t.Fatalf("guard fired after only %d repeats (threshold %d)", stormBreakThreshold-1, stormBreakThreshold)
}
if len(*notices) != 0 {
t.Errorf("no notice expected below threshold, got %v", *notices)
}
}
// TestStormBreakerResetsOnSuccess: a run of failures broken by a successful turn
// must reset the counter, so the guard does not fire prematurely afterward.
func TestStormBreakerResetsOnSuccess(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(failTool{name: "write_file"})
reg.Add(okTool{name: "read_file"})
sink, notices := noticeRecorder()
a := New(nil, reg, NewSession(""), Options{}, sink)
fail := provider.ToolCall{Name: "write_file", Arguments: `{"content":"x`}
good := provider.ToolCall{Name: "read_file", Arguments: `{"path":"x"}`}
ctx := context.Background()
a.executeBatch(ctx, &a.turn, []provider.ToolCall{fail}) // count 1
a.executeBatch(ctx, &a.turn, []provider.ToolCall{fail}) // count 2
a.executeBatch(ctx, &a.turn, []provider.ToolCall{good}) // success → reset
a.executeBatch(ctx, &a.turn, []provider.ToolCall{fail}) // count 1
last := executeBatchOutputs(a, ctx, []provider.ToolCall{fail})[0] // count 2 — still below threshold
if strings.Contains(last, "[loop guard]") {
t.Fatalf("guard should have reset after a successful turn, got: %q", last)
}
if len(*notices) != 0 {
t.Errorf("no notice expected when a success breaks the run, got %v", *notices)
}
}
// executeBatchOutputs runs the batch and returns just the model-facing outputs.
func executeBatchOutputs(a *Agent, ctx context.Context, calls []provider.ToolCall) []string {
batch := a.executeBatch(ctx, &a.turn, calls)
outputs := batch.results
return outputs
}
+7 -34
View File
@@ -13,45 +13,18 @@ type taskRuntime struct {
ledger *evidence.Ledger
outcome *evidence.OutcomeTracker
budget runBudget
// repeatFailures outlives a Run: re-reading a file and resending the same
// stale anchor is still zero progress, so prepareScope — not the ledger
// restart — decides what survives a scope-stable continuation.
repeatFailures map[string]repeatFailureRecord
repeatScope string
}
// restartLedger begins a new task's accounting. It is written as one assignment
// so a field added to taskRuntime resets by default; the four fields carried
// forward are named because each answers to its own condition in beginRunTurn.
// so a field added to taskRuntime resets by default; the fields carried forward
// are named because each answers to its own condition in beginRunTurn.
func (t *taskRuntime) restartLedger() {
*t = taskRuntime{
scopeID: t.scopeID,
checkpoint: t.checkpoint,
repeatFailures: t.repeatFailures,
repeatScope: t.repeatScope,
ledger: t.ledger,
outcome: evidence.NewOutcomeTracker(),
budget: runBudget{limit: t.budget.limit},
scopeID: t.scopeID,
checkpoint: t.checkpoint,
ledger: t.ledger,
outcome: evidence.NewOutcomeTracker(),
budget: runBudget{limit: t.budget.limit},
}
t.ledger.Reset()
}
// prepareScope reconciles the repeat-failure records against the scope this Run
// belongs to. A scope-stable continuation keeps only the records whose anchor
// still needs re-checking; anything else starts from empty.
func (t *taskRuntime) prepareScope(scoped bool, scopeID string) {
if !scoped || t.repeatScope != scopeID {
t.repeatFailures = nil
} else {
for sig, failure := range t.repeatFailures {
if !failure.stateRecheck {
delete(t.repeatFailures, sig)
}
}
}
if scoped {
t.repeatScope = scopeID
} else {
t.repeatScope = ""
}
}
+8 -54
View File
@@ -14,23 +14,19 @@ import (
// "one ledger, one task, one bill" a property of the type rather than of the
// call sites that used to clear these fields one by one.
var taskCarryOver = map[string]bool{
"scopeID": true,
"checkpoint": true,
"repeatFailures": true,
"repeatScope": true,
"ledger": true, // identity, not contents: executeOne hands the pointer to every tool context
"scopeID": true,
"checkpoint": true,
"ledger": true, // identity, not contents: executeOne hands the pointer to every tool context
}
func TestTaskRuntimeRestartCarriesScopeAndResetsAccounting(t *testing.T) {
ledger := evidence.NewLedger()
before := &taskRuntime{
scopeID: "scope-1",
checkpoint: evidence.DeliveryCheckpoint{ScopeID: "scope-1"},
ledger: ledger,
outcome: evidence.NewOutcomeTracker(),
budget: runBudget{rounds: 4, requests: 9, cost: 1.5, limit: TaskBudget{}},
repeatFailures: map[string]repeatFailureRecord{"sig": {count: 2}},
repeatScope: "scope-1",
scopeID: "scope-1",
checkpoint: evidence.DeliveryCheckpoint{ScopeID: "scope-1"},
ledger: ledger,
outcome: evidence.NewOutcomeTracker(),
budget: runBudget{rounds: 4, requests: 9, cost: 1.5, limit: TaskBudget{}},
}
after := *before
after.restartLedger()
@@ -38,9 +34,6 @@ func TestTaskRuntimeRestartCarriesScopeAndResetsAccounting(t *testing.T) {
if after.scopeID != "scope-1" || after.checkpoint.ScopeID != "scope-1" {
t.Errorf("scope = %q/%q, want it carried: beginRunTurn owns the scope transition", after.scopeID, after.checkpoint.ScopeID)
}
if after.repeatScope != "scope-1" || len(after.repeatFailures) != 1 {
t.Errorf("repeat failures = %q/%d, want them carried: prepareScope decides what survives", after.repeatScope, len(after.repeatFailures))
}
if after.ledger != ledger {
t.Error("ledger pointer replaced; tool contexts hold it for the length of a call")
}
@@ -106,42 +99,3 @@ func TestTaskRuntimeLifetimeListsCoverTheStruct(t *testing.T) {
}
}
}
func TestTaskRuntimePrepareScopeKeepsOnlyRecheckableFailures(t *testing.T) {
records := func() map[string]repeatFailureRecord {
return map[string]repeatFailureRecord{
"stale": {count: 2, stateRecheck: true},
"solved": {count: 1},
}
}
cases := []struct {
name string
scoped bool
scopeID string
want []string
}{
{"same scope keeps the anchors still worth re-checking", true, "scope-1", []string{"stale"}},
{"a new scope is a new task", true, "scope-2", nil},
{"an unscoped run keeps nothing", false, "", nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
task := taskRuntime{repeatFailures: records(), repeatScope: "scope-1"}
task.prepareScope(tc.scoped, tc.scopeID)
if len(task.repeatFailures) != len(tc.want) {
t.Fatalf("repeatFailures = %v, want %v", task.repeatFailures, tc.want)
}
for _, sig := range tc.want {
if _, ok := task.repeatFailures[sig]; !ok {
t.Errorf("repeatFailures lost %q", sig)
}
}
if tc.scoped && task.repeatScope != tc.scopeID {
t.Errorf("repeatScope = %q, want %q", task.repeatScope, tc.scopeID)
}
if !tc.scoped && task.repeatScope != "" {
t.Errorf("repeatScope = %q, want it cleared", task.repeatScope)
}
})
}
}
-199
View File
@@ -1,199 +0,0 @@
package agent
import (
"context"
"errors"
"fmt"
"testing"
"reasonix/internal/agent/testutil"
"reasonix/internal/event"
"reasonix/internal/evidence"
"reasonix/internal/provider"
"reasonix/internal/tool"
_ "reasonix/internal/tool/builtin"
)
// 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"}]}`,
}}}}
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...)
return New(mp, reg, NewSession(""), Options{}, event.Discard), mp
}
// 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")
}
})
}
}
func TestGoalTodoProgressGuardReplansWithoutPausing(t *testing.T) {
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; maxTodoStallRounds exact repeats
// after it reach the Goal redirect threshold.
for i := range maxTodoStallRounds + 1 {
turns = append(turns, testutil.Turn{ToolCalls: []provider.ToolCall{{
ID: fmt.Sprintf("read-%d", i), Name: "inspect", Arguments: `{"path":"same"}`,
}}})
}
turns = append(turns, testutil.Turn{Text: "Replanned; a real blocker would be reported through update_goal."})
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)
ctx := WithDeliveryExecutionScope(context.Background(), DeliveryExecutionScope{ID: "goal-1", TaskText: "finish the task"})
if err := a.Run(ctx, "work until the todo is complete"); err != nil {
t.Fatalf("Goal todo stall must redirect, not pause: %v", err)
}
if !sessionContains(a, "Host progress redirect") {
t.Fatal("Goal todo stall did not inject a re-plan redirect")
}
}
func TestTodoProgressGuardRenewsOnUniqueHostWork(t *testing.T) {
turns := []testutil.Turn{{ToolCalls: []provider.ToolCall{{
ID: "todo", Name: "todo_write",
Arguments: `{"todos":[{"content":"finish the task","status":"in_progress"}]}`,
}}}}
for i := range todoProgressNudgeRounds - 1 {
turns = append(turns, testutil.Turn{ToolCalls: []provider.ToolCall{{
ID: fmt.Sprintf("read-a-%d", i), Name: "inspect", Arguments: `{"path":"same"}`,
}}})
}
turns = append(turns,
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "write", Name: "write_file", Arguments: `{"path":"result.txt","content":"done"}`}}},
)
for i := range todoProgressNudgeRounds - 1 {
turns = append(turns, testutil.Turn{ToolCalls: []provider.ToolCall{{
ID: fmt.Sprintf("read-b-%d", i), Name: "inspect", Arguments: `{"path":"same"}`,
}}})
}
turns = append(turns,
testutil.Turn{ToolCalls: []provider.ToolCall{{
ID: "done", Name: "complete_step",
Arguments: `{"step":"finish the task","result":"done","evidence":[{"kind":"files","summary":"created result","paths":["result.txt"]}]}`,
}}},
testutil.Turn{Text: "done"},
)
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "inspect", readOnly: true})
reg.Add(fakeTool{name: "write_file", readOnly: false})
reg.Add(mustBuiltinTool(t, "todo_write"))
reg.Add(mustBuiltinTool(t, "complete_step"))
a := New(testutil.NewMock("m", turns...), reg, NewSession(""), Options{}, event.Discard)
if err := a.Run(context.Background(), "finish the todo"); err != nil {
t.Fatalf("Run: %v", err)
}
if sessionContains(a, "Host progress check") {
t.Fatal("unique host work should renew the progress lease before the nudge threshold")
}
}
func TestCanonicalTodoProgressIgnoresTitleAndPendingListChurn(t *testing.T) {
a := &Agent{sess: sessionRuntime{todoState: []evidence.TodoItem{
{Content: "finish the task", Status: "in_progress"},
{Content: "write tests", Status: "pending"},
}}}
before, tracking := a.canonicalTodoProgress()
if !tracking {
t.Fatal("incomplete todo list should be tracked")
}
a.setTodoState([]evidence.TodoItem{
{Content: "finish the task carefully", Status: "in_progress"},
{Content: "write tests", Status: "pending"},
{Content: "update docs", Status: "pending"},
})
after, tracking := a.canonicalTodoProgress()
if !tracking || after != before {
t.Fatalf("title/pending churn changed progress from %d to %d", before, after)
}
}
func TestMaxStepsGraceSummaryBypassesIncompleteTodoReadiness(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(mustBuiltinTool(t, "todo_write"))
reg.Add(fakeTool{name: "write_file", readOnly: false})
mp := testutil.NewMock("m",
testutil.Turn{ToolCalls: []provider.ToolCall{
{ID: "todo", Name: "todo_write", Arguments: `{"todos":[{"content":"unfinished","status":"in_progress"}]}`},
{ID: "write", Name: "write_file", Arguments: `{"path":"unfinished.txt"}`},
}},
testutil.Turn{Text: "Progress saved; the todo remains unfinished."},
)
a := New(mp, reg, NewSession(""), Options{MaxSteps: 1, DeliveryProfile: true}, event.Discard)
err := a.Run(context.Background(), "start a long task")
var pause *maxStepsPause
if !errors.As(err, &pause) {
t.Fatalf("Run error = %v, want maxStepsPause instead of final-readiness retries", err)
}
if mp.CallCount() != 2 {
t.Fatalf("provider calls = %d, want tool round plus one summary round", mp.CallCount())
}
}
func mustBuiltinTool(t *testing.T, name string) tool.Tool {
t.Helper()
builtin, ok := tool.LookupBuiltin(name)
if !ok {
t.Fatalf("builtin %q is not registered", name)
}
return builtin
}
-30
View File
@@ -21,11 +21,6 @@ type turnRuntime struct {
graceRound bool
recoveryGraceRound bool
todoProgress int
trackingTodoProgress bool
todoStallRounds int
seenTodoProgress map[string]struct{}
executorHandoff bool
input string
workDurationMs func() int64
@@ -57,21 +52,6 @@ type turnRuntime struct {
// task, rather than the root controller transcript.
recoveryTaskSummary string
// blockedTurnStreak counts consecutive rounds the host blocked outright.
// stormSig catches fixation on one call shape; this catches rotation
// between blocked shapes, which is zero progress all the same.
blockedTurnStreak int
// loopGuardArmed stands final readiness down after a loop guard fired:
// demanding receipts the blocker prevents would restart the loop. The mark
// is the pre-batch ledger count, so later progress revokes the pass.
loopGuardArmed bool
loopGuardReceiptMark int
// repeatSuccessCounts catches the shape stormSig cannot see: the same write
// succeeding over and over leaves no error for a failure-only breaker.
repeatSuccessCounts map[string]int
// policy is frozen at the start of the Run and never observes a mid-turn
// SetAgentPreset change. policySet marks that beginRunTurn derived it.
policy taskpolicy.TaskPolicy
@@ -79,16 +59,6 @@ type turnRuntime struct {
// reviewWarnings are warn-level findings to surface in the final summary.
reviewWarnings []string
// stormSig keys on (tool, error/blocker), NOT (tool, args): a stuck model
// reworks arguments cosmetically while the host returns the same refusal,
// so keying on args misses the loop entirely. See applyStormBreaker.
stormSig string
stormCount int
// progress escalates adaptively on consecutive zero-evidence-gain rounds;
// see progress_guard.go.
progress progressGuard
}
// pendingTurn is what someone outside the Run arms for the next one: a
+1 -1
View File
@@ -1990,7 +1990,7 @@ func (c *Controller) runReady(ctx context.Context, input string) (err error) {
defer func() { c.hooks.StopResult(context.Background(), lastAssistantText(c.History()), turn, err) }()
}
marker = c.markInFlightTurn(startMessages, true)
ctx = c.withPlannerTurnMetadata(ctx, rawInput, false, startMessages)
ctx = c.withPlannerTurnMetadata(ctx, rawInput, false)
err = c.runner.Run(ctx, c.withCapabilityRoute(ctx, input, rawInput))
return err
}
+43 -391
View File
@@ -2,65 +2,41 @@ package control
import (
"context"
"regexp"
"slices"
"strings"
"unicode"
"unicode/utf8"
"reasonix/internal/agent"
"reasonix/internal/agentpreset"
"reasonix/internal/taskpolicy"
)
// The planner gate answers one question: did the user ask for a plan? Twelve
// keyword tables used to score the answer out of vocabulary; the executor reads
// the same words with more context and plans for itself when a task needs it.
const (
plannerLightResearchRounds = 2
plannerFullResearchRounds = 6
)
const (
plannerReasonExplicitPlanMode = "explicit_plan_mode"
plannerReasonSynthetic = "synthetic"
plannerReasonSlash = "slash_command"
plannerReasonShortReply = "short_reply"
plannerReasonConversation = "conversation"
plannerReasonUserDirect = "user_direct"
plannerReasonUserPlanOnly = "user_plan_only"
plannerReasonUserPlanApproval = "user_plan_for_approval"
plannerReasonUserPlanAndExecute = "user_plan_and_execute"
plannerReasonContextContinuation = "context_continuation"
plannerReasonLowRiskQuestion = "low_risk_question"
plannerReasonHighRisk = "high_risk"
plannerReasonCrossSurface = "cross_surface"
plannerReasonStructuredRequest = "structured_request"
plannerReasonComplexIntent = "complex_intent"
plannerReasonAtomicEdit = "atomic_edit"
plannerReasonReadOnlyAction = "read_only_action"
plannerReasonGuidance = "complex_guidance"
plannerReasonGoalActive = "goal_active"
plannerReasonAnchoredWork = "anchored_work"
plannerReasonAmbiguousWork = "ambiguous_work"
plannerReasonWorkRequest = "work_request"
plannerReasonTaskPolicy = "task_policy"
plannerReasonDefault = "default_executor"
)
var (
directOptionReplyRE = regexp.MustCompile(`(?i)^\s*(?:\d+|[a-z])\s*[.)、。]?\s*$`)
prefixedOptionReplyRE = regexp.MustCompile(`(?i)^\s*(?:选|选择|就|用|按|走|执行|choose|pick|use|option|choice|方案)\s*(?:第\s*)?(?:方案|选项|option|choice)?\s*(?:\d+|[一二三四五六七八九十]|[a-z])\s*(?:个|号|项|种|条|方案|option|choice)?\s*[.)、。!!??]?\s*$`)
plannerListRE = regexp.MustCompile(`(?m)^\s*(?:[-*]|\d+[.)、])\s+\S`)
plannerFileRefRE = regexp.MustCompile(`(?i)(?:^|[\s@` + "`" + `"'(])(?:[\w.-]+[/\\])*[\w.-]+\.(?:go|ts|tsx|js|jsx|py|rs|java|kt|md|json|ya?ml|toml|sql|sh|css|html)(?:$|[\s,;:!?,;:!?)` + "`" + `"'])`)
plannerReasonExplicitPlanMode = "explicit_plan_mode"
plannerReasonSynthetic = "synthetic"
plannerReasonSlash = "slash_command"
plannerReasonEmpty = "empty_turn"
plannerReasonUserPlanOnly = "user_plan_only"
plannerReasonUserPlanApproval = "user_plan_for_approval"
plannerReasonUserPlanAndExecute = "user_plan_and_execute"
plannerReasonExecutorOwns = "executor_owns_the_turn"
)
type plannerTurnMetadata struct {
UserText string
Synthetic bool
ExplicitPlanMode bool
GoalActive bool
DeliveryProfile bool // legacy tests/callers without a frozen TaskPolicy
HasConversationContext bool
Policy taskpolicy.TaskPolicy
PolicySet bool
UserText string
Synthetic bool
ExplicitPlanMode bool
Policy taskpolicy.TaskPolicy
PolicySet bool
}
type plannerTurnMetadataKey struct{}
@@ -77,37 +53,26 @@ func plannerTurnMetadataFromContext(ctx context.Context) (plannerTurnMetadata, b
return meta, ok
}
func (c *Controller) withPlannerTurnMetadata(ctx context.Context, userText string, synthetic bool, priorMessages int) context.Context {
func (c *Controller) withPlannerTurnMetadata(ctx context.Context, userText string, synthetic bool) context.Context {
text := strings.TrimSpace(agent.StripTransientUserBlocks(userText))
features := plannerFeaturesFor(text, normalizePlannerText(text))
policy := taskpolicy.Derive(taskpolicy.Input{
Raw: text,
Instruction: taskpolicy.StripQuotedConstraints(text),
Preset: agentpreset.Normalize(c.AgentPreset()),
PlanMode: c.PlanMode(),
HighRiskHints: features.highRisk,
MediumRiskHints: features.complex,
MultiFile: features.multiFile,
CrossSurface: features.crossSurface,
Anchored: features.anchored,
Structured: features.structured,
Raw: text,
Instruction: taskpolicy.StripQuotedConstraints(text),
Preset: agentpreset.Normalize(c.AgentPreset()),
PlanMode: c.PlanMode(),
})
ctx = taskpolicy.WithContext(ctx, policy)
return withPlannerTurnMetadata(ctx, plannerTurnMetadata{
UserText: userText,
Synthetic: synthetic,
ExplicitPlanMode: c.PlanMode(),
GoalActive: c.goals.active(),
DeliveryProfile: policy.Preset == agentpreset.Delivery,
HasConversationContext: priorMessages > 1,
Policy: policy,
PolicySet: true,
UserText: userText,
Synthetic: synthetic,
ExplicitPlanMode: c.PlanMode(),
Policy: policy,
PolicySet: true,
})
}
// DecidePlannerRoute applies deterministic precedence rules to a pristine user
// turn plus trusted host metadata. It never calls a model and never parses
// controller-injected XML to infer host state.
// DecidePlannerRoute routes a turn to the two-model planner only when the user
// asked for a plan. It never calls a model and never scores the task text.
func DecidePlannerRoute(ctx context.Context, input string) agent.PlannerDecision {
meta, hasMeta := plannerTurnMetadataFromContext(ctx)
composedText := strings.TrimSpace(agent.StripTransientUserBlocks(input))
@@ -123,17 +88,11 @@ func DecidePlannerRoute(ctx context.Context, input string) agent.PlannerDecision
return plannerExecutorDecision(plannerReasonSynthetic)
}
if text == "" {
return plannerExecutorDecision(plannerReasonConversation)
return plannerExecutorDecision(plannerReasonEmpty)
}
if strings.HasPrefix(text, "/") {
return plannerExecutorDecision(plannerReasonSlash)
}
if isContextDependentShortReply(text) {
return plannerExecutorDecision(plannerReasonShortReply)
}
if isConversationalTurn(text) {
return plannerExecutorDecision(plannerReasonConversation)
}
lower := normalizePlannerText(text)
if requestsPlanApproval(lower) {
@@ -145,67 +104,10 @@ func DecidePlannerRoute(ctx context.Context, input string) agent.PlannerDecision
if hasLeadingDirective(lower, planAndExecuteDirectives) || hasLeadingDirective(lower, planFirstDirectives) {
return plannerPlanDecision(agent.PlannerRoutePlanAndExecute, agent.PlannerDepthFull, plannerReasonUserPlanAndExecute)
}
if requestsDirectExecution(lower) && (!meta.PolicySet || meta.Policy.Route == taskpolicy.RouteDirect) {
return plannerExecutorDecision(plannerReasonUserDirect)
}
if meta.HasConversationContext && isContextDependentAction(text) {
return plannerExecutorDecision(plannerReasonContextContinuation)
}
if isLowRiskQuestion(lower) {
return plannerExecutorDecision(plannerReasonLowRiskQuestion)
}
features := plannerFeaturesFor(text, lower)
if meta.PolicySet {
if meta.GoalActive && features.work && !features.atomic {
return plannerPlanDecision(agent.PlannerRoutePlanAndExecute, agent.PlannerDepthFull, plannerReasonGoalActive)
}
switch meta.Policy.Route {
case taskpolicy.RouteFullPlan:
return plannerPlanDecision(agent.PlannerRoutePlanAndExecute, agent.PlannerDepthFull, plannerReasonTaskPolicy)
case taskpolicy.RouteLightPlan:
return plannerPlanDecision(agent.PlannerRoutePlanAndExecute, agent.PlannerDepthLight, plannerReasonTaskPolicy)
default:
return plannerExecutorDecision(plannerReasonTaskPolicy)
}
}
if features.work && features.highRisk {
return plannerPlanDecision(agent.PlannerRoutePlanAndExecute, agent.PlannerDepthFull, plannerReasonHighRisk)
}
if features.multiFile || features.crossSurface {
return plannerPlanDecision(agent.PlannerRoutePlanAndExecute, agent.PlannerDepthFull, plannerReasonCrossSurface)
}
if features.structured {
return plannerPlanDecision(agent.PlannerRoutePlanAndExecute, agent.PlannerDepthFull, plannerReasonStructuredRequest)
}
if features.complex {
return plannerPlanDecision(agent.PlannerRoutePlanAndExecute, agent.PlannerDepthFull, plannerReasonComplexIntent)
}
if features.atomic {
return plannerExecutorDecision(plannerReasonAtomicEdit)
}
if features.guidance {
return plannerPlanDecision(agent.PlannerRoutePlanAndExecute, agent.PlannerDepthLight, plannerReasonGuidance)
}
if features.readOnly && !features.ambiguous {
return plannerExecutorDecision(plannerReasonReadOnlyAction)
}
if meta.GoalActive && features.work {
return plannerPlanDecision(agent.PlannerRoutePlanAndExecute, agent.PlannerDepthFull, plannerReasonGoalActive)
}
if meta.DeliveryProfile && features.work {
return plannerPlanDecision(agent.PlannerRoutePlanAndExecute, agent.PlannerDepthFull, plannerReasonWorkRequest)
}
if features.work && features.ambiguous {
return plannerPlanDecision(agent.PlannerRoutePlanAndExecute, agent.PlannerDepthFull, plannerReasonAmbiguousWork)
}
if features.work && features.anchored {
return plannerPlanDecision(agent.PlannerRoutePlanAndExecute, agent.PlannerDepthLight, plannerReasonAnchoredWork)
}
if features.work {
return plannerPlanDecision(agent.PlannerRoutePlanAndExecute, agent.PlannerDepthLight, plannerReasonWorkRequest)
}
return plannerExecutorDecision(plannerReasonDefault)
// Nobody asked for a plan. The executor decides for itself whether this
// turn needs steps, and states them with todo_write where the delivery
// gates and the contract can both read them.
return plannerExecutorDecision(plannerReasonExecutorOwns)
}
func plannerExecutorDecision(reason string) agent.PlannerDecision {
@@ -229,50 +131,6 @@ func plannerPlanDecision(route agent.PlannerRoute, depth agent.PlannerDepth, rea
}
}
type plannerFeatures struct {
work bool
highRisk bool
multiFile bool
crossSurface bool
structured bool
complex bool
atomic bool
readOnly bool
guidance bool
anchored bool
ambiguous bool
}
func plannerFeaturesFor(text, lower string) plannerFeatures {
fileRefs := plannerFileRefRE.FindAllString(text, -1)
anchored := len(fileRefs) > 0 || strings.Contains(text, "@") || containsAnyLexical(lower, plannerNamedTargets)
work := containsAnyLexical(lower, plannerWorkTerms)
highRisk := containsAnyLexical(lower, plannerHighRiskTerms)
multiFile := len(fileRefs) >= 2 || strings.Count(text, "@") >= 2
crossSurface := containsAnyLexical(lower, plannerCrossSurfaceTerms)
structured := utf8.RuneCountInString(text) >= 240 || plannerListRE.MatchString(text) || strings.Count(text, "\n") >= 2
complex := containsAnyLexical(lower, complexIntentTerms)
guidance := isComplexGuidanceQuestion(lower)
ambiguous := work && containsAnyLexical(lower, plannerAmbiguousScopeTerms)
readOnly := work && containsAnyLexical(lower, plannerReadOnlyWorkTerms) &&
!containsAnyLexical(lower, plannerMutationWorkTerms)
atomic := work && anchored && !highRisk && !multiFile && !crossSurface && !structured && !complex &&
utf8.RuneCountInString(text) <= 140 && containsAnyLexical(lower, plannerAtomicTerms)
return plannerFeatures{
work: work,
highRisk: highRisk,
multiFile: multiFile,
crossSurface: crossSurface,
structured: structured,
complex: complex,
atomic: atomic,
readOnly: readOnly,
guidance: guidance,
anchored: anchored,
ambiguous: ambiguous,
}
}
func normalizePlannerText(text string) string {
text = strings.ToLower(strings.TrimSpace(text))
text = strings.ReplaceAll(text, "’", "'")
@@ -295,6 +153,10 @@ func hasLeadingDirective(lower string, directives []string) bool {
return false
}
// The tables below read directives, not topics: each entry is a way of saying
// "plan this first" or "wait for my approval". They bind the host because the
// user said them, which is the difference from the scoring this file dropped.
var planAndExecuteDirectives = []string{
"先规划再执行", "先规划再实现", "先出方案再执行", "先出方案再实现",
"plan first, then", "plan first then", "plan then implement", "plan and implement",
@@ -328,12 +190,11 @@ var plannerApprovalTerms = []string{
"等我批准", "等待我批准", "我批准后", "批准后再",
"wait for my approval", "wait for approval", "after i approve", "after my approval",
"until i approve", "until my approval", "let me approve", "let me confirm",
"after i confirm", "after my confirmation",
"确认后执行", "批准后执行",
}
var directExecutionDirectives = []string{
"直接改", "直接修改", "直接做", "直接执行", "别规划", "不要规划", "无需规划",
"just do it", "skip the plan",
var plannerIntentTerms = []string{
"plan", "planning", "方案", "规划", "计划",
}
func requestsPlanOnly(lower string) bool {
@@ -349,44 +210,15 @@ func requestsPlanOnly(lower string) bool {
return true
}
return containsAnyLexical(directiveText, plannerNoExecutionTerms) &&
(containsAnyLexical(directiveText, plannerIntentTerms) ||
containsAnyLexical(directiveText, plannerWorkTerms))
containsAnyLexical(directiveText, plannerIntentTerms)
}
func requestsPlanApproval(lower string) bool {
directiveText := plannerDirectiveText(lower)
return (containsAnyLexical(directiveText, plannerIntentTerms) ||
containsAnyLexical(directiveText, plannerWorkTerms)) &&
return containsAnyLexical(directiveText, plannerIntentTerms) &&
containsUnnegatedPlannerApproval(directiveText)
}
func requestsDirectExecution(lower string) bool {
directiveText := plannerDirectiveText(lower)
if containsAnyLexical(directiveText, directExecutionDirectives) {
return true
}
for _, term := range []string{"don't plan", "do not plan"} {
offset := 0
for offset < len(directiveText) {
idx := strings.Index(directiveText[offset:], term)
if idx < 0 {
break
}
idx += offset
after := strings.TrimSpace(directiveText[idx+len(term):])
if !strings.HasPrefix(after, "to ") {
return true
}
offset = idx + len(term)
}
}
return false
}
var plannerIntentTerms = []string{
"plan", "planning", "方案", "规划", "计划",
}
func containsUnnegatedPlannerApproval(text string) bool {
for _, term := range plannerApprovalTerms {
offset := 0
@@ -487,119 +319,6 @@ func plannerInlineApostrophe(runes []rune, i int) bool {
(unicode.IsLetter(runes[i+1]) || unicode.IsDigit(runes[i+1]))
}
func isContextDependentAction(text string) bool {
text = strings.TrimSpace(text)
if text == "" || strings.ContainsAny(text, "\n\r") || utf8.RuneCountInString(text) > 48 {
return false
}
if plannerFileRefRE.MatchString(text) || strings.Contains(text, "@") {
return false
}
lower := normalizePlannerText(text)
for _, prefix := range []string{
"fix it", "fix this", "do it", "apply it", "make that change", "go ahead with it",
"修一下", "改一下", "按这个改", "照这个做", "执行这个", "就这么改", "修复这个问题",
} {
if strings.HasPrefix(lower, prefix) {
return true
}
}
return false
}
func isConversationalTurn(text string) bool {
normalized := strings.Trim(strings.ToLower(strings.TrimSpace(text)), " \t\r\n.!?。!?,,;;::")
return conversationalTurns[normalized]
}
var conversationalTurns = map[string]bool{
"hello": true, "hi": true, "hey": true, "thanks": true, "thank you": true,
"你好": true, "您好": true, "谢谢": true, "辛苦了": true, "收到": true, "明白": true,
}
func isContextDependentShortReply(text string) bool {
text = strings.TrimSpace(text)
if text == "" || strings.ContainsAny(text, "\n\r") {
return false
}
if directOptionReplyRE.MatchString(text) || prefixedOptionReplyRE.MatchString(text) {
return true
}
lower := strings.ToLower(text)
if containsAnyLexical(lower, complexIntentTerms) || containsAnyLexical(lower, plannerWorkTerms) {
return false
}
if shortContextReplies[lower] {
return true
}
if utf8.RuneCountInString(text) > 16 {
return false
}
for _, prefix := range shortContextReplyPrefixes {
if strings.HasPrefix(lower, prefix) {
return true
}
}
return false
}
var shortContextReplies = map[string]bool{
"ok": true, "okay": true, "yes": true, "y": true, "no": true, "n": true,
"sure": true, "go ahead": true, "proceed": true, "continue": true, "next": true,
"sounds good": true, "好": true, "好的": true, "可以": true, "行": true,
"嗯": true, "对": true, "是": true, "确认": true, "同意": true, "继续": true,
"继续吧": true, "下一步": true, "开始": true, "开始吧": true, "执行": true,
"就这样": true, "没问题": true,
}
var shortContextReplyPrefixes = []string{
"继续", "执行", "开始", "下一步", "go ahead", "proceed", "continue",
}
func isLowRiskQuestion(lower string) bool {
lower = strings.TrimSpace(lower)
normalized := strings.ReplaceAll(lower, "'", "")
if strings.HasPrefix(lower, "what ") || strings.HasPrefix(normalized, "whats ") ||
strings.HasPrefix(lower, "why ") || strings.HasPrefix(lower, "how ") ||
strings.HasPrefix(lower, "who ") || strings.HasPrefix(lower, "where ") ||
strings.HasPrefix(lower, "when ") || strings.HasPrefix(lower, "which ") ||
strings.HasPrefix(lower, "whose ") || strings.HasPrefix(lower, "whom ") ||
strings.HasPrefix(lower, "explain ") || strings.HasPrefix(lower, "describe ") ||
strings.HasPrefix(lower, "tell ") || strings.HasPrefix(lower, "show ") ||
strings.HasPrefix(lower, "list ") || strings.HasPrefix(lower, "summarize ") ||
strings.HasPrefix(lower, "summarise ") || strings.HasPrefix(lower, "compare ") ||
strings.HasPrefix(lower, "difference ") || strings.HasPrefix(lower, "is ") ||
strings.HasPrefix(lower, "are ") || strings.HasPrefix(lower, "can ") ||
strings.HasPrefix(lower, "could ") || strings.HasPrefix(lower, "do ") ||
strings.HasPrefix(lower, "does ") || strings.HasPrefix(lower, "did ") ||
strings.HasPrefix(lower, "should ") || strings.HasPrefix(lower, "would ") ||
strings.HasPrefix(lower, "will ") ||
strings.HasPrefix(lower, "what's") || strings.HasPrefix(normalized, "whats") ||
strings.HasPrefix(lower, "解释") || strings.HasPrefix(lower, "说明") ||
strings.HasPrefix(lower, "怎么看") || strings.HasPrefix(lower, "查一下") ||
strings.HasPrefix(lower, "介绍一下") ||
strings.HasPrefix(lower, "说一下") || strings.HasPrefix(lower, "帮我看") ||
strings.HasPrefix(lower, "帮我查") || strings.HasPrefix(lower, "是什么") ||
strings.HasPrefix(lower, "有没有") || strings.HasPrefix(lower, "能不能") ||
strings.HasPrefix(lower, "可以吗") || strings.HasPrefix(lower, "对吗") ||
strings.HasPrefix(lower, "是不是") || strings.HasPrefix(lower, "请问") {
return !containsAnyLexical(lower, plannerQuestionWorkTerms)
}
return false
}
func isComplexGuidanceQuestion(lower string) bool {
for _, prefix := range []string{
"how do i ", "how should i ", "how would you ", "what's the best way ",
"what is the best way ", "explain how to ", "怎么实现", "如何实现", "怎么迁移", "如何迁移",
} {
if strings.HasPrefix(lower, prefix) {
return true
}
}
return false
}
func containsAnyLexical(s string, terms []string) bool {
for _, term := range terms {
if containsLexicalTerm(s, term) {
@@ -631,73 +350,6 @@ func containsNonASCII(s string) bool {
return false
}
var complexIntentTerms = []string{
"refactor", "migrate", "migration", "redesign", "end-to-end", "e2e", "wire up",
"integration", "architecture", "release", "package", "重构", "迁移", "改造",
"端到端", "联调", "接入", "架构", "发布", "打包",
}
var plannerWorkTerms = []string{
"fix", "fixing", "update", "updating", "remove", "removing", "delete", "deleting",
"edit", "editing", "write", "writing", "create", "creating", "add", "adding", "repair",
"patch", "run", "running", "build", "building", "implement", "implementing", "refactor",
"refactoring", "migrate", "migrating", "redesign", "review", "reviewing", "audit",
"inspect", "debug", "test", "tests", "testing", "修改", "修复", "更新", "删除", "移除",
"编辑", "写入", "创建", "新增", "添加", "运行", "构建", "实现", "重构", "迁移",
"改造", "评审", "审查", "排查", "调试", "测试", "加个", "加一", "补一个", "补个",
}
var plannerMutationWorkTerms = []string{
"fix", "fixing", "update", "updating", "remove", "removing", "delete", "deleting",
"edit", "editing", "write", "writing", "create", "creating", "add", "adding", "repair",
"patch", "build", "building", "implement", "implementing", "refactor", "refactoring",
"migrate", "migrating", "redesign", "修改", "修复", "更新", "删除", "移除", "编辑",
"写入", "创建", "新增", "添加", "构建", "实现", "重构", "迁移", "改造", "加个",
"加一", "补一个", "补个",
}
var plannerReadOnlyWorkTerms = []string{
"run", "running", "review", "reviewing", "audit", "inspect", "debug",
"test", "tests", "testing", "运行", "评审", "审查", "排查", "调试", "测试",
}
var plannerQuestionWorkTerms = []string{
"fix", "fixing", "update", "updating", "remove", "removing", "delete", "deleting",
"edit", "editing", "write", "writing", "create", "creating", "add", "adding", "repair",
"patch", "implement", "implementing", "refactor", "refactoring", "migrate", "migrating",
"redesign", "修改", "修复", "更新", "删除", "移除", "编辑", "写入", "创建", "新增",
"添加", "实现", "重构", "迁移", "改造", "加个", "加一", "补一个", "补个",
}
var plannerHighRiskTerms = []string{
"auth", "authentication", "authorization", "permission", "token", "secret",
"credential", "payment", "billing", "race", "concurrency", "deadlock", "transaction",
"encryption", "signature", "sandbox", "privilege", "权限", "鉴权", "认证", "令牌",
"密钥", "支付", "账单", "并发", "竞态", "竟态", "死锁", "事务", "加密", "签名",
"沙箱", "提权",
}
var plannerCrossSurfaceTerms = []string{
"multiple files", "several files", "across", "frontend and backend", "backend and frontend",
"api and ui", "ui and api", "database and api", "多个文件", "多处", "前后端",
"整个模块", "整个项目", "全链路", "跨模块",
}
var plannerAtomicTerms = []string{
"typo", "wording", "copy", "readme", "changelog", "nil check", "null check",
"log line", "one line", "rename", "文案", "错别字", "拼写", "空指针检查",
"nil 检查", "一行日志", "改名", "重命名",
}
var plannerNamedTargets = []string{
"readme", "changelog", "makefile", "dockerfile",
}
var plannerAmbiguousScopeTerms = []string{
"the bug", "the issue", "the problem", "performance", "everything", "whole module",
"这个 bug", "这个bug", "这个问题", "性能", "整个模块", "全部问题",
}
// TaskWarrantsPlanner is retained as a small compatibility predicate for
// callers and tests that do not need depth or approval semantics.
func TaskWarrantsPlanner(input string) bool {
+96 -440
View File
@@ -5,482 +5,138 @@ import (
"testing"
"reasonix/internal/agent"
"reasonix/internal/taskpolicy"
)
func TestTaskWarrantsPlanner(t *testing.T) {
cases := []struct {
input string
want bool
}{
{"", false},
{" ", false},
{"/init", false},
{"1", false},
{"2.", false},
{"A", false},
{"好的", false},
{"继续", false},
{"选 1", false},
{"what does this function do?", false}, // low-risk question → executor only
{"why did the test fail", false},
{"解释一下这段代码", false},
{reasoningLanguageBlock("zh") + "\n\nwhat does this function do?", false},
{reasoningLanguageBlock("en") + "\n\n" + PlanModeMarker + "\n\nfix the bug", false},
{reasoningLanguageBlock("en") + "\n\nfix the bug", true},
{"fix the bug", true}, // terse, but a work request → still planned
{"add a login button", true}, // ditto
{"run the tests", false},
{"review this PR", false},
{"inspect internal/foo.go", false},
{"执行修复", true},
{"开始迁移", true},
{"继续重构", true},
{"continue fixing tests", true},
{"implement the new caching layer across the backend", true},
{"who wrote this file?", false},
{"where is the config file?", false},
{"when does this run?", false},
{"which file has the error?", false},
{"explain this code", false},
{"describe the architecture", false},
{"tell me about this function", false},
{"is this safe?", false},
{"are we done?", false},
{"can you help?", false},
{"can you fix the failing tests across the backend", true},
{"could you update the README", false}, // explicit single-target edit → executor only
{"should we remove the stale config option", true},
{"would you add a regression test", true},
{"do you fix flaky tests here", true},
{"does it work?", false},
{"did the test pass?", false},
{"should I use mutex here?", false},
{"would this approach work?", false},
{"list all the endpoints", false},
{"summarize the changes", false},
{"compare these two approaches", false},
{"what's the status?", false},
{"介绍一下这个项目", false},
{"说一下这个函数的作用", false},
{"帮我看一下这个报错", false},
{"是什么意思", false},
{"有没有现成的方案", false},
{"能不能这样做", false},
{"请问这个怎么用", false},
{"how do I implement a new caching layer", true},
{"what's the best way to refactor this module", true},
{"explain how to migrate from v1 to v2", true},
{goalContinueTurn, false},
{"Goal signaled complete but issues remain:\n- the following tasks are still incomplete:\n - Fix login (in_progress)\nFix or use todo_write/complete_step to mark done, then report complete again via update_goal.", false},
{activeGoalBlock("execute plan: fix the parser") + "\n\n" + goalContinueTurn, false},
{activeGoalBlock("implement the new caching layer") + "\n\nimplement the new caching layer across the backend", true},
}
for _, c := range cases {
if got := TaskWarrantsPlanner(c.input); got != c.want {
t.Errorf("TaskWarrantsPlanner(%q) = %v, want %v", c.input, got, c.want)
// The gate answers "did the user ask for a plan?", so a turn that reads as
// work — however large, however risky-sounding — still belongs to the executor
// until someone says otherwise. These are the inputs the retired scoring layer
// used to buy a planner round for.
func TestWorkShapedTurnsStayWithTheExecutor(t *testing.T) {
for _, input := range []string{
"fix the bug",
"add a login button",
"执行修复",
"开始迁移",
"继续重构",
"implement the new caching layer across the backend",
"migrate authentication to the new provider and update every caller",
"refactor parser.go, reader.go and writer.go so they share one lexer",
"drop the legacy table in production and backfill from the archive",
"1. read the config\n2. rewrite the loader\n3. run the tests",
"how do I implement a new caching layer",
"what's the best way to refactor this module",
"what does this function do?",
"解释一下这段代码",
"帮我看一下这个报错",
"review this PR",
"run the tests",
"继续",
"好的",
} {
if got := DecidePlannerRoute(context.Background(), input); got.Route != agent.PlannerRouteExecutorOnly {
t.Errorf("%q routed to %s (%s); nobody asked for a plan", input, got.Route, got.Reason)
}
}
}
func TestTaskPolicyOwnsAutomaticPlannerRoute(t *testing.T) {
// Host facts still short-circuit: plan mode owns its own workflow, and a turn
// the host synthesized is not a user asking for anything.
func TestHostFactsRouteToTheExecutor(t *testing.T) {
cases := []struct {
name string
policy taskpolicy.TaskPolicy
input string
depth agent.PlannerDepth
meta plannerTurnMetadata
reason string
}{
{"direct", taskpolicy.TaskPolicy{Route: taskpolicy.RouteDirect}, "fix the parser", agent.PlannerDepthNone},
{"light", taskpolicy.TaskPolicy{Route: taskpolicy.RouteLightPlan}, "fix parser.go and parser_test.go", agent.PlannerDepthLight},
{"full overrides direct request", taskpolicy.TaskPolicy{Route: taskpolicy.RouteFullPlan}, "do it directly: migrate authentication", agent.PlannerDepthFull},
{"plan mode marker", PlanModeMarker + "\n\n先规划再执行:重写解析器", plannerTurnMetadata{}, plannerReasonExplicitPlanMode},
{"plan mode flag", "先规划再执行:重写解析器", plannerTurnMetadata{ExplicitPlanMode: true}, plannerReasonExplicitPlanMode},
{"synthetic turn", goalContinueTurn, plannerTurnMetadata{}, plannerReasonSynthetic},
{"synthetic flag", "先规划再执行:重写解析器", plannerTurnMetadata{Synthetic: true}, plannerReasonSynthetic},
{"empty", " ", plannerTurnMetadata{}, plannerReasonEmpty},
{"slash command", "/init", plannerTurnMetadata{}, plannerReasonSlash},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx := withPlannerTurnMetadata(context.Background(), plannerTurnMetadata{Policy: tc.policy, PolicySet: true})
got := DecidePlannerRoute(ctx, tc.input)
if got.Depth != tc.depth || got.Reason != plannerReasonTaskPolicy {
t.Fatalf("decision = %+v, want task-policy depth %s", got, tc.depth)
got := DecidePlannerRoute(withPlannerTurnMetadata(context.Background(), tc.meta), tc.input)
if got.Route != agent.PlannerRouteExecutorOnly || got.Reason != tc.reason {
t.Fatalf("decision = %+v, want executor-only for %s", got, tc.reason)
}
})
}
}
func TestNewPlannerGateUsesDeterministicTaskPolicy(t *testing.T) {
gate := NewPlannerGate()
if gate == nil {
t.Fatal("NewPlannerGate returned nil")
}
if got := gate(context.Background(), "what is this?"); got {
t.Error("planner gate should skip low-risk questions")
}
if got := gate(context.Background(), "fix the bug"); !got {
t.Error("planner gate should plan work requests")
}
}
func TestDecidePlannerRouteMatrix(t *testing.T) {
// What still buys a planner round: the user saying so.
func TestStatedDirectivesRouteToThePlanner(t *testing.T) {
cases := []struct {
name string
input string
meta plannerTurnMetadata
route agent.PlannerRoute
depth agent.PlannerDepth
reason string
name string
input string
route agent.PlannerRoute
}{
{
name: "explicit plan mode bypasses dual planner",
input: "fix the bug",
meta: plannerTurnMetadata{ExplicitPlanMode: true},
route: agent.PlannerRouteExecutorOnly,
depth: agent.PlannerDepthNone,
reason: plannerReasonExplicitPlanMode,
},
{
name: "trusted synthetic turn bypasses dual planner",
input: "perform a brand new implementation",
meta: plannerTurnMetadata{Synthetic: true},
route: agent.PlannerRouteExecutorOnly,
depth: agent.PlannerDepthNone,
reason: plannerReasonSynthetic,
},
{
name: "user asks for plan only",
input: "先规划这个认证迁移,不要执行",
route: agent.PlannerRoutePlanOnly,
depth: agent.PlannerDepthFull,
reason: plannerReasonUserPlanOnly,
},
{
name: "bare english plan only",
input: "Give me a plan only for the auth migration.",
route: agent.PlannerRoutePlanOnly,
depth: agent.PlannerDepthFull,
reason: plannerReasonUserPlanOnly,
},
{
name: "bare chinese plan only with embedded target",
input: "只给认证迁移方案",
route: agent.PlannerRoutePlanOnly,
depth: agent.PlannerDepthFull,
reason: plannerReasonUserPlanOnly,
},
{
name: "task first english plan only boundary",
input: "Review the auth migration and give me a plan only; do not execute.",
route: agent.PlannerRoutePlanOnly,
depth: agent.PlannerDepthFull,
reason: plannerReasonUserPlanOnly,
},
{
name: "task first english plan only without redundant no execution",
input: "Review the auth migration and give me a plan only.",
route: agent.PlannerRoutePlanOnly,
depth: agent.PlannerDepthFull,
reason: plannerReasonUserPlanOnly,
},
{
name: "work request with no execution boundary",
input: "Implement the auth migration, but do not execute.",
route: agent.PlannerRoutePlanOnly,
depth: agent.PlannerDepthFull,
reason: plannerReasonUserPlanOnly,
},
{
name: "task first chinese plan only boundary",
input: "评审认证迁移,给我方案即可,不要修改代码",
route: agent.PlannerRoutePlanOnly,
depth: agent.PlannerDepthFull,
reason: plannerReasonUserPlanOnly,
},
{
name: "bare plan first continues to executor",
input: "先规划这个认证迁移",
route: agent.PlannerRoutePlanAndExecute,
depth: agent.PlannerDepthFull,
reason: plannerReasonUserPlanAndExecute,
},
{
name: "english plan first continues to executor",
input: "plan first, then handle the authentication migration",
route: agent.PlannerRoutePlanAndExecute,
depth: agent.PlannerDepthFull,
reason: plannerReasonUserPlanAndExecute,
},
{
name: "user asks to approve plan before execution",
input: "先规划这个认证迁移,等我确认后再执行",
route: agent.PlannerRoutePlanForApproval,
depth: agent.PlannerDepthFull,
reason: plannerReasonUserPlanApproval,
},
{
name: "task first english approval boundary",
input: "Implement the auth migration, but show me the plan and wait for my approval.",
route: agent.PlannerRoutePlanForApproval,
depth: agent.PlannerDepthFull,
reason: plannerReasonUserPlanApproval,
},
{
name: "work request with approval boundary",
input: "Implement the auth migration and wait for my approval.",
route: agent.PlannerRoutePlanForApproval,
depth: agent.PlannerDepthFull,
reason: plannerReasonUserPlanApproval,
},
{
name: "task first chinese approval boundary",
input: "实现认证迁移,但先给我方案并等我确认",
route: agent.PlannerRoutePlanForApproval,
depth: agent.PlannerDepthFull,
reason: plannerReasonUserPlanApproval,
},
{
name: "conditional no execution remains approval boundary",
input: "Plan the auth migration and do not execute until I approve.",
route: agent.PlannerRoutePlanForApproval,
depth: agent.PlannerDepthFull,
reason: plannerReasonUserPlanApproval,
},
{
name: "user asks to plan then execute",
input: "先规划再执行这个认证迁移",
route: agent.PlannerRoutePlanAndExecute,
depth: agent.PlannerDepthFull,
reason: plannerReasonUserPlanAndExecute,
},
{
name: "user explicitly skips planner",
input: "直接改 auth.go,别规划",
route: agent.PlannerRouteExecutorOnly,
depth: agent.PlannerDepthNone,
reason: plannerReasonUserDirect,
},
{
name: "task first english direct execution boundary",
input: "Implement the auth migration, but do not plan.",
route: agent.PlannerRouteExecutorOnly,
depth: agent.PlannerDepthNone,
reason: plannerReasonUserDirect,
},
{
name: "do not plan to is a scope constraint not a routing directive",
input: "Plan the auth migration, but do not plan to change the database schema.",
route: agent.PlannerRoutePlanAndExecute,
depth: agent.PlannerDepthFull,
reason: plannerReasonComplexIntent,
},
{
name: "task first chinese direct execution boundary",
input: "实现认证迁移,别规划,直接改",
route: agent.PlannerRouteExecutorOnly,
depth: agent.PlannerDepthNone,
reason: plannerReasonUserDirect,
},
{
name: "quoted directive is not an override",
input: "解释“直接改”是什么意思",
route: agent.PlannerRouteExecutorOnly,
depth: agent.PlannerDepthNone,
reason: plannerReasonLowRiskQuestion,
},
{
name: "quoted no execution phrase is not a boundary",
input: "解释“不要执行”是什么意思",
route: agent.PlannerRouteExecutorOnly,
depth: agent.PlannerDepthNone,
reason: plannerReasonLowRiskQuestion,
},
{
name: "quoted english approval phrase is not a boundary",
input: "Explain what \"wait for my approval\" means.",
route: agent.PlannerRouteExecutorOnly,
depth: agent.PlannerDepthNone,
reason: plannerReasonLowRiskQuestion,
},
{
name: "single quoted english boundary is not an override",
input: "Review the README wording 'wait for my approval'.",
route: agent.PlannerRouteExecutorOnly,
depth: agent.PlannerDepthNone,
reason: plannerReasonAtomicEdit,
},
{
name: "apostrophe in contraction remains a boundary",
input: "Review the README but don't execute.",
route: agent.PlannerRoutePlanOnly,
depth: agent.PlannerDepthFull,
reason: plannerReasonUserPlanOnly,
},
{
name: "expanded approval negation is not an approval boundary",
input: "Plan the migration; you do not need to wait for my approval.",
route: agent.PlannerRoutePlanAndExecute,
depth: agent.PlannerDepthFull,
reason: plannerReasonComplexIntent,
},
{
name: "negated approval does not override plan only",
input: "只给认证迁移方案,不要执行,也不用等我确认",
route: agent.PlannerRoutePlanOnly,
depth: agent.PlannerDepthFull,
reason: plannerReasonUserPlanOnly,
},
{
name: "context dependent fix stays with executor",
input: "fix it",
meta: plannerTurnMetadata{HasConversationContext: true},
route: agent.PlannerRouteExecutorOnly,
depth: agent.PlannerDepthNone,
reason: plannerReasonContextContinuation,
},
{
name: "standalone context dependent fix remains ambiguous",
input: "fix it",
route: agent.PlannerRoutePlanAndExecute,
depth: agent.PlannerDepthLight,
reason: plannerReasonWorkRequest,
},
{
name: "atomic readme edit skips planner",
input: "fix typo in README",
route: agent.PlannerRouteExecutorOnly,
depth: agent.PlannerDepthNone,
reason: plannerReasonAtomicEdit,
},
{
name: "atomic nil check skips planner",
input: "add a nil check in internal/foo.go",
route: agent.PlannerRouteExecutorOnly,
depth: agent.PlannerDepthNone,
reason: plannerReasonAtomicEdit,
},
{
name: "bounded anchored work gets light plan",
input: "add regression coverage in internal/foo_test.go",
route: agent.PlannerRoutePlanAndExecute,
depth: agent.PlannerDepthLight,
reason: plannerReasonAnchoredWork,
},
{
name: "single step read only command skips planner",
input: "run the tests",
route: agent.PlannerRouteExecutorOnly,
depth: agent.PlannerDepthNone,
reason: plannerReasonReadOnlyAction,
},
{
name: "single file inspection skips planner",
input: "inspect internal/foo.go",
route: agent.PlannerRouteExecutorOnly,
depth: agent.PlannerDepthNone,
reason: plannerReasonReadOnlyAction,
},
{
name: "delivery keeps pure read only command direct",
input: "review this PR",
meta: plannerTurnMetadata{DeliveryProfile: true},
route: agent.PlannerRouteExecutorOnly,
depth: agent.PlannerDepthNone,
reason: plannerReasonReadOnlyAction,
},
{
name: "high risk read only audit still gets full plan",
input: "audit the authentication authorization flow",
route: agent.PlannerRoutePlanAndExecute,
depth: agent.PlannerDepthFull,
reason: plannerReasonHighRisk,
},
{
name: "ambiguous bug gets full plan",
input: "fix the bug",
route: agent.PlannerRoutePlanAndExecute,
depth: agent.PlannerDepthFull,
reason: plannerReasonAmbiguousWork,
},
{
name: "anchored ambiguous bug still gets full plan",
input: "fix the bug in internal/foo.go",
route: agent.PlannerRoutePlanAndExecute,
depth: agent.PlannerDepthFull,
reason: plannerReasonAmbiguousWork,
},
{
name: "high risk overrides single target",
input: "fix the token authorization race in auth.go",
route: agent.PlannerRoutePlanAndExecute,
depth: agent.PlannerDepthFull,
reason: plannerReasonHighRisk,
},
{
name: "cross surface gets full plan",
input: "update frontend and backend for the new profile field",
route: agent.PlannerRoutePlanAndExecute,
depth: agent.PlannerDepthFull,
reason: plannerReasonCrossSurface,
},
{
name: "complex guidance gets light plan",
input: "how do I implement a local cache?",
route: agent.PlannerRoutePlanAndExecute,
depth: agent.PlannerDepthLight,
reason: plannerReasonGuidance,
},
{
name: "delivery upgrades non atomic work",
input: "add a login button",
meta: plannerTurnMetadata{DeliveryProfile: true},
route: agent.PlannerRoutePlanAndExecute,
depth: agent.PlannerDepthFull,
reason: plannerReasonWorkRequest,
},
{
name: "delivery keeps atomic edit direct",
input: "fix typo in README",
meta: plannerTurnMetadata{DeliveryProfile: true},
route: agent.PlannerRouteExecutorOnly,
depth: agent.PlannerDepthNone,
reason: plannerReasonAtomicEdit,
},
{
name: "active goal upgrades non atomic work",
input: "add a login button",
meta: plannerTurnMetadata{GoalActive: true},
route: agent.PlannerRoutePlanAndExecute,
depth: agent.PlannerDepthFull,
reason: plannerReasonGoalActive,
},
{
name: "active goal alone does not force an atomic edit through planner",
input: "fix typo in README",
meta: plannerTurnMetadata{GoalActive: true},
route: agent.PlannerRouteExecutorOnly,
depth: agent.PlannerDepthNone,
reason: plannerReasonAtomicEdit,
},
{"plan first then execute", "先规划再执行:重写解析器", agent.PlannerRoutePlanAndExecute},
{"plan first en", "plan first, then rewrite the parser", agent.PlannerRoutePlanAndExecute},
{"make a plan", "make a plan for the parser rewrite", agent.PlannerRoutePlanAndExecute},
{"plan only", "只给方案,不要执行", agent.PlannerRoutePlanOnly},
{"plan only en", "give me a plan only for the parser rewrite", agent.PlannerRoutePlanOnly},
{"plan for approval", "给我方案,等我确认后再执行", agent.PlannerRoutePlanForApproval},
{"plan for approval en", "draft a plan and wait for my approval", agent.PlannerRoutePlanForApproval},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx := withPlannerTurnMetadata(context.Background(), tc.meta)
got := DecidePlannerRoute(ctx, tc.input)
if got.Route != tc.route || got.Depth != tc.depth || got.Reason != tc.reason {
t.Fatalf("decision = %+v, want route=%s depth=%s reason=%s", got, tc.route, tc.depth, tc.reason)
got := DecidePlannerRoute(context.Background(), tc.input)
if got.Route != tc.route {
t.Fatalf("decision = %+v, want %s", got, tc.route)
}
if got.Route != agent.PlannerRouteExecutorOnly && got.MaxResearchRounds <= 0 {
if got.MaxResearchRounds <= 0 {
t.Fatalf("planned decision has no research budget: %+v", got)
}
})
}
}
// A directive quoted as an example is the user describing one, not issuing one.
func TestQuotedDirectivesDoNotBind(t *testing.T) {
for _, input := range []string{
`the docs say "plan first, then implement" — is that still true?`,
`用户说“先规划再执行”是什么意思`,
} {
if got := DecidePlannerRoute(context.Background(), input); got.Route != agent.PlannerRouteExecutorOnly {
t.Errorf("%q routed to %s from a quoted directive", input, got.Route)
}
}
}
// Routing reads the pristine user text, never the blocks the controller wrapped
// around it: a goal block naming a migration must not speak for the user.
func TestPlannerPolicyUsesPristineMetadataInsteadOfInjectedContext(t *testing.T) {
ctx := withPlannerTurnMetadata(context.Background(), plannerTurnMetadata{
UserText: "fix typo in README",
})
input := activeGoalBlock("migrate authentication across the backend") +
input := activeGoalBlock("先规划再执行:migrate authentication") +
"\n\n<capability-route>\nhigh risk migration\n</capability-route>\n\nfix typo in README"
got := DecidePlannerRoute(ctx, input)
if got.Route != agent.PlannerRouteExecutorOnly || got.Reason != plannerReasonAtomicEdit {
if got.Route != agent.PlannerRouteExecutorOnly || got.Reason != plannerReasonExecutorOwns {
t.Fatalf("decision used injected context instead of pristine user text: %+v", got)
}
}
func TestTaskWarrantsPlannerTracksTheRoute(t *testing.T) {
if TaskWarrantsPlanner("fix the bug") {
t.Error("an ordinary work request must not warrant the planner")
}
if !TaskWarrantsPlanner("先规划再执行:重写解析器") {
t.Error("a stated plan-first directive must warrant the planner")
}
}
func TestNewPlannerGateTracksTheRoute(t *testing.T) {
gate := NewPlannerGate()
if gate == nil {
t.Fatal("NewPlannerGate returned nil")
}
if gate(context.Background(), "what is this?") {
t.Error("planner gate should leave a question with the executor")
}
if !gate(context.Background(), "make a plan for the parser rewrite") {
t.Error("planner gate should honour a stated plan request")
}
}
+5 -6
View File
@@ -74,7 +74,7 @@ func (o *turnOrchestrator) runGoalContinuationTurnWithRawDisplay(
func (o *turnOrchestrator) runComposedSyntheticTurn(ctx context.Context, text string) error {
c := o.c
ctx = agent.WithRawUserInput(ctx, text)
ctx = c.withPlannerTurnMetadata(ctx, text, true, c.messageCount())
ctx = c.withPlannerTurnMetadata(ctx, text, true)
return c.runner.Run(ctx, c.ComposeSynthetic(text))
}
@@ -130,7 +130,7 @@ func (o *turnOrchestrator) runSubagentSkillTurns(ctx context.Context, skills []s
ctx = agent.WithResponseLanguagePreference(ctx, c.responseLanguage)
ctx = agent.WithReasoningLanguagePreference(ctx, c.reasoningLanguage)
input := c.compose(task, raw, true)
input := c.imageRoutingPrefix(unreadableImages(images, imageCandidates)) + c.compose(task, raw, true)
startMessages := c.messageCount()
var marker agent.InFlightTurnMeta
defer func() { c.finishInFlightTurn(startMessages, marker) }()
@@ -202,9 +202,7 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
parentSession := c.parentSessionID()
ctx = agent.WithParentSession(ctx, parentSession)
ctx = jobs.WithSession(ctx, parentSession)
userImages, imageCandidates := c.imagesForOrchestratedTurn(ctx, turn)
ctx = agent.WithUserImages(ctx, userImages)
ctx = agent.WithSubagentImageCandidates(ctx, imageCandidates)
ctx, userImages, unreadable := c.bindOrchestratedTurnImages(ctx, turn)
ctx = agent.WithRawUserInput(ctx, turn.raw)
continuation := turn.goalContinuation
var input string
@@ -219,6 +217,7 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
} else {
input = c.compose(turn.input, turn.raw, !turn.synthetic)
}
input = c.imageRoutingPrefix(unreadable) + input
// input.receive: the composed text crosses the extension chain before it
// enters the session (checkpoint, hooks, and the model all see the final
// text). A block ruling aborts the turn with the redacted reason surfaced,
@@ -281,7 +280,7 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
if !turn.synthetic {
modelInput = c.withCapabilityRoute(ctx, input, turn.raw)
}
ctx = c.withPlannerTurnMetadata(ctx, turn.raw, turn.synthetic, startMessages)
ctx = c.withPlannerTurnMetadata(ctx, turn.raw, turn.synthetic)
// Real user turns open a fresh Recovery Episode. Goal auto-continues and
// other synthetic turns inherit the current Episode so budgets accumulate
// only within one host-owned execution round.
+1 -4
View File
@@ -72,12 +72,9 @@ func TestTurnOrchestratorAttachesTrustedPlannerMetadata(t *testing.T) {
if runner.meta.UserText != raw {
t.Fatalf("planner metadata user text = %q, want pristine %q", runner.meta.UserText, raw)
}
if runner.meta.ExplicitPlanMode || !runner.meta.GoalActive || !runner.meta.DeliveryProfile {
if runner.meta.ExplicitPlanMode || !runner.meta.PolicySet {
t.Fatalf("planner metadata missing trusted host state: %+v", runner.meta)
}
if !runner.meta.HasConversationContext {
t.Fatalf("planner metadata lost executor conversation ownership: %+v", runner.meta)
}
if !strings.Contains(runner.input, expanded) {
t.Fatalf("model input lost expanded context: %q", runner.input)
}
+11 -29
View File
@@ -89,19 +89,11 @@ type Input struct {
Preset agentpreset.AgentPreset
// PlanMode is the collaboration plan-mode flag.
PlanMode bool
// HighRiskHints are host signals (permission/auth/release/security class).
// The host computes them from what the turn touches; this package never
// re-derives risk from the text itself.
HighRiskHints bool
// MediumRiskHints are host signals (cross-module / migration).
// HighRiskHints and MediumRiskHints are host signals about what the turn
// touches. Nothing derives them from the message's wording or shape, which
// described the sentence rather than the work.
HighRiskHints bool
MediumRiskHints bool
// MultiFile / CrossSurface come from planner-gate style features.
MultiFile bool
CrossSurface bool
// Anchored means the user named concrete files or targets.
Anchored bool
// Structured is true for multi-step structured requests.
Structured bool
}
// TaskPolicy is the authoritative host policy for one turn.
@@ -142,13 +134,13 @@ func Derive(in Input) TaskPolicy {
securityClass := in.HighRiskHints
risk := RiskLow
if in.MediumRiskHints || in.MultiFile || in.CrossSurface || in.Structured {
if in.MediumRiskHints {
risk = RiskMedium
}
if in.HighRiskHints {
risk = RiskHigh
}
route := chooseRoute(policy, risk, in)
route := chooseRoute(policy, risk)
verification := policy.VerificationPolicy.Level
if constraints.RequireFullVerification || !constraints.ForbidTests && risk >= RiskHigh {
if policy.VerificationPolicy.Level < VerifyFull && (constraints.RequireFullVerification || risk >= RiskHigh) {
@@ -281,28 +273,18 @@ func (p TaskPolicy) RequiresSecurityReview() bool {
return p.Review == ReviewForcedSecurity
}
func chooseRoute(policy agentpreset.PresetPolicy, risk Risk, in Input) Route {
if risk == RiskLow && !in.MultiFile && !in.Structured && !in.CrossSurface {
func chooseRoute(policy agentpreset.PresetPolicy, risk Risk) Route {
if risk == RiskLow {
return RouteDirect
}
if risk >= RiskHigh && policy.PlannerPolicy.FullPlanOnHighRisk {
return RouteFullPlan
}
if risk >= RiskMedium && policy.PlannerPolicy.FullPlanOnMediumRisk {
if policy.PlannerPolicy.FullPlanOnMediumRisk {
return RouteFullPlan
}
if in.CrossSurface || (in.Structured && risk >= RiskMedium) {
if policy.PlannerPolicy.FullPlanOnMediumRisk || risk >= RiskHigh {
return RouteFullPlan
}
}
if in.MultiFile || in.Structured {
if policy.PlannerPolicy.PreferLightPlan {
return RouteLightPlan
}
if policy.PlannerPolicy.FullPlanOnMediumRisk {
return RouteFullPlan
}
if policy.PlannerPolicy.PreferLightPlan {
return RouteLightPlan
}
if policy.PlannerPolicy.DirectOK {
return RouteDirect
+26 -21
View File
@@ -42,15 +42,13 @@ func TestDeriveLightHighRiskElevates(t *testing.T) {
func TestDeriveDeliveryLowRiskNoReviewer(t *testing.T) {
p := Derive(Input{
Raw: "fix the typo in README.md",
Preset: agentpreset.Delivery,
Anchored: true,
Raw: "fix the typo in README.md",
Preset: agentpreset.Delivery,
})
if p.Risk != RiskLow {
// typo fix is low risk
t.Logf("risk = %v (may be medium if multi-file heuristics fire)", p.Risk)
t.Fatalf("risk = %v, want low without a host signal", p.Risk)
}
if p.Risk == RiskLow && p.RequiresIndependentReview() {
if p.RequiresIndependentReview() {
t.Fatal("delivery low-risk must not force independent reviewer")
}
}
@@ -73,10 +71,9 @@ func TestDeriveIgnoresTopicWordsWithoutHostSignals(t *testing.T) {
func TestDeriveDeliveryMediumForcesReview(t *testing.T) {
p := Derive(Input{
Raw: "refactor the payment module and update its callers",
Preset: agentpreset.Delivery,
MultiFile: true,
CrossSurface: true,
Raw: "refactor the payment module and update its callers",
Preset: agentpreset.Delivery,
MediumRiskHints: true,
})
if p.Risk < RiskMedium {
t.Fatalf("risk = %v, want at least medium", p.Risk)
@@ -188,23 +185,31 @@ func TestExecutionPolicyBlockStable(t *testing.T) {
}
func TestMatrixPlanningRoutes(t *testing.T) {
// Balanced multi-file → light plan
// A host medium-risk signal still routes Balanced into a plan.
p := Derive(Input{
Raw: "update the API and its tests",
Preset: agentpreset.Balanced,
MultiFile: true,
Raw: "update the API and its tests",
Preset: agentpreset.Balanced,
MediumRiskHints: true,
})
if p.Route != RouteLightPlan && p.Route != RouteFullPlan {
t.Fatalf("balanced multi-file route = %v", p.Route)
t.Fatalf("balanced medium-risk route = %v", p.Route)
}
// Delivery multi-file medium → full plan
// Delivery escalates the same signal to a full plan.
d := Derive(Input{
Raw: "update the API and its tests across packages",
Preset: agentpreset.Delivery,
MultiFile: true,
Structured: true,
Raw: "update the API and its tests across packages",
Preset: agentpreset.Delivery,
MediumRiskHints: true,
})
if d.Route != RouteFullPlan {
t.Fatalf("delivery structured multi-file route = %v, want full", d.Route)
t.Fatalf("delivery medium-risk route = %v, want full", d.Route)
}
// Without a host signal nothing about the message buys a plan.
for _, raw := range []string{
"update the API and its tests across packages",
"1. read the config\n2. rewrite the loader\n3. run the tests",
} {
if got := Derive(Input{Raw: raw, Preset: agentpreset.Delivery}); got.Route != RouteDirect {
t.Errorf("%q routed to %v from its own shape", raw, got.Route)
}
}
}