mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
b071fef950
commit
21638e356c
32 files changed
+263
-3513
No files matched your search
@@ -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"`
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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 := ¬iceSink{}
|
||||
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 := ¬iceSink{}
|
||||
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: ¬iceSink{}}}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¬ices
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user