返回 DeepSeek-Reasonix
progress.go
根目录 / internal / evidence / progress.go
1 package evidence
2
3 import "strings"
4
5 // Evidence-gain weights: what one tool round contributed beyond what the turn
6 // already knew. Positive = the round moved the investigation; zero = motion
7 // without information (repeats); negative = burning rounds on a known failure.
8 const (
9 gainNewRead = 1 // first successful read of a path
10 gainNewCommand = 1 // first successful run of a command
11 gainNewAction = 1 // first successful (tool, args) call — covers MCP/custom tools
12 gainNewFailure = 2 // a command failing for the first time localizes an error
13 gainStateChange = 2 // a previously failing command now passes (or vice versa)
14 gainMutation = 3 // a successful write/mutation
15 gainTaskProgress = 1 // todo/step bookkeeping that advances task state
16 gainDelegated = 2 // a sub-agent round-trip that returned
17 gainRepeatFailure = -2
18 )
19
20 // explorationRunLimit is how many consecutive look-only rounds still count as
21 // progress. Deep investigation rarely runs longer before touching something;
22 // a wandering loop runs until someone notices.
23 const explorationRunLimit = 6
24
25 // ProgressTracker scores each tool round's receipts for new evidence so the
26 // agent can react to stalled investigation adaptively instead of at a fixed
27 // round count. State is per user turn, like the ledger it observes.
28 type ProgressTracker struct {
29 readPaths map[string]bool
30 commandRuns map[string]bool
31 commandFail map[string]bool
32 actionSigs map[string]bool
33 exploreRun int
34 }
35
36 func NewProgressTracker() *ProgressTracker {
37 return &ProgressTracker{
38 readPaths: map[string]bool{},
39 commandRuns: map[string]bool{},
40 commandFail: map[string]bool{},
41 actionSigs: map[string]bool{},
42 }
43 }
44
45 // ScoreRound folds one round's receipts into the tracker and returns the
46 // round's evidence gain. Reading something new is progress only while it leads
47 // somewhere: past explorationRunLimit look-only rounds the novelty stops
48 // counting, because a loop that keeps opening files it has never opened scored
49 // positive every round and so could never reach the no-progress ladder.
50 func (t *ProgressTracker) ScoreRound(receipts []Receipt) int {
51 if t == nil {
52 return 0
53 }
54 gain := 0
55 acted := false
56 for _, r := range receipts {
57 gain += t.scoreReceipt(r)
58 acted = acted || roundActedOn(r)
59 }
60 if acted {
61 t.exploreRun = 0
62 return gain
63 }
64 t.exploreRun++
65 if t.exploreRun > explorationRunLimit {
66 return 0
67 }
68 return gain
69 }
70
71 // roundActedOn reports whether a receipt did something other than look: a
72 // mutation, or any command run. Either one means the exploration led somewhere
73 // and the run starts over.
74 func roundActedOn(r Receipt) bool {
75 return r.Mutation || r.Write || strings.TrimSpace(r.Command) != ""
76 }
77
78 func (t *ProgressTracker) scoreReceipt(r Receipt) int {
79 if command := strings.TrimSpace(r.Command); command != "" {
80 return t.scoreCommand(command, r.Success)
81 }
82 switch {
83 case r.Success && (r.Mutation || r.Write):
84 return gainMutation
85 case r.Success && (r.ToolName == "task" || r.ToolName == "parallel_tasks" || r.ToolName == "fleet"):
86 return gainDelegated
87 case r.Success && (r.StepProof || r.TodoStep != nil || len(r.Todos) > 0):
88 return gainTaskProgress
89 case r.Success && r.Read && r.OutputBytes > 0 && len(r.Paths) > 0:
90 return t.scoreReads(r)
91 case r.Success:
92 if t.noteQuestion(r) {
93 return gainNewAction
94 }
95 return 0
96 default:
97 return 0
98 }
99 }
100
101 func (t *ProgressTracker) noteQuestion(r Receipt) bool {
102 sig := r.ToolName + "\x00" + string(r.Args)
103 if t.actionSigs[sig] {
104 return false
105 }
106 t.actionSigs[sig] = true
107 return true
108 }
109
110 func (t *ProgressTracker) scoreReads(r Receipt) int {
111 gain := 0
112 for _, path := range r.Paths {
113 if path == "" || t.readPaths[path] {
114 continue
115 }
116 t.readPaths[path] = true
117 gain += gainNewRead
118 }
119 if newQuestion := t.noteQuestion(r); gain == 0 && newQuestion {
120 return gainNewRead
121 }
122 return gain
123 }
124
125 func (t *ProgressTracker) scoreCommand(command string, success bool) int {
126 seen := t.commandRuns[command]
127 failedBefore := t.commandFail[command]
128 t.commandRuns[command] = true
129 if !success {
130 t.commandFail[command] = true
131 if failedBefore {
132 return gainRepeatFailure
133 }
134 return gainNewFailure
135 }
136 delete(t.commandFail, command)
137 if failedBefore {
138 return gainStateChange
139 }
140 if seen {
141 return 0
142 }
143 return gainNewCommand
144 }
145
146 // ReceiptsSince returns a copy of the receipts recorded at or after index.
147 func (l *Ledger) ReceiptsSince(index int) []Receipt {
148 if l == nil {
149 return nil
150 }
151 l.mu.Lock()
152 defer l.mu.Unlock()
153 if index < 0 {
154 index = 0
155 }
156 if index >= len(l.receipts) {
157 return nil
158 }
159 return append([]Receipt(nil), l.receipts[index:]...)
160 }
161
162 // Receipts returns a copy of every receipt recorded this turn, in order —
163 // the replay feed for shadow observers that must not share ledger memory.
164 func (l *Ledger) Receipts() []Receipt {
165 if l == nil {
166 return nil
167 }
168 l.mu.Lock()
169 defer l.mu.Unlock()
170 return append([]Receipt(nil), l.receipts...)
171 }
172
172 lines GO