-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathloopguard_test.go
More file actions
247 lines (220 loc) · 8.72 KB
/
Copy pathloopguard_test.go
File metadata and controls
247 lines (220 loc) · 8.72 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
package dun
// The guard has to be precise in BOTH directions, and the second one is what
// makes it hard: a detector that interrupts normal iteration is worse than no
// detector, because what it interrupts is someone working. So these tests pin
// the refusals AND the things that must never be refused.
import (
"context"
"strings"
"testing"
"github.com/iodesystems/agentkit/llm"
)
// counting is a dispatcher that records what actually reached it.
type counting struct {
calls []string
result string
}
func (c *counting) dispatch(_ context.Context, tc llm.ToolCall) (string, error) {
c.calls = append(c.calls, tc.Function.Name+" "+tc.Function.Arguments)
if c.result == "" {
return "ok", nil
}
return c.result, nil
}
func call(name, args string) llm.ToolCall {
var tc llm.ToolCall
tc.Type = "function"
tc.Function.Name = name
tc.Function.Arguments = args
return tc
}
// The measured loop, replayed: twelve back-to-back `recap` calls with identical
// arguments (yscr 2026-08-23, calls 39–50). The first folded 379 entries; the
// eleven after it each folded one and wrote a 2,523-byte file. Two should run
// and ten should never reach the tool.
func TestLoopGuard_RefusesTheMeasuredRecapLoop(t *testing.T) {
h := newNoteHarness(t)
inner := &counting{result: "Done — recap: 1 entries (~2333 chars) → …recap18.jsonl"}
d := withLoopGuard(inner.dispatch, h)
args := `{"from":"Let's get started","summary":"SHIPPED this session"}`
var refusals int
for i := 0; i < 12; i++ {
out, err := d(context.Background(), call("recap", args))
if err != nil {
t.Fatalf("call %d: %v", i, err)
}
if strings.HasPrefix(out, "ERROR: this call was NOT run") {
refusals++
}
}
if len(inner.calls) != defaultLoopRepeats-1 {
t.Errorf("%d calls reached the tool, want %d", len(inner.calls), defaultLoopRepeats-1)
}
if refusals != 12-(defaultLoopRepeats-1) {
t.Errorf("%d refusals, want %d", refusals, 12-(defaultLoopRepeats-1))
}
}
// The refusal has to say why repeating cannot help and quote what the model
// already got. Told only "refused", a model reads a transport failure and
// retries — which is the loop.
func TestLoopGuard_RefusalCarriesThePreviousResult(t *testing.T) {
h := newNoteHarness(t)
inner := &counting{result: "nothing to fold: 0 entries matched"}
d := withLoopGuard(inner.dispatch, h)
var out string
for i := 0; i < defaultLoopRepeats; i++ {
out, _ = d(context.Background(), call("recap", `{"from":"x"}`))
}
for _, want := range []string{"NOT run", "recap", "nothing to fold", "something DIFFERENT"} {
if !strings.Contains(out, want) {
t.Errorf("refusal is missing %q:\n%s", want, out)
}
}
}
// Legitimate repetition, from the same session: `ship {"mode":"push"}` eight
// times and one `git log` three times, every pair separated by other work. The
// run is what is being counted, not the total.
func TestLoopGuard_LeavesSeparatedRepeatsAlone(t *testing.T) {
h := newNoteHarness(t)
inner := &counting{}
d := withLoopGuard(inner.dispatch, h)
for i := 0; i < 8; i++ {
if out, _ := d(context.Background(), call("ship", `{"mode":"push"}`)); strings.HasPrefix(out, "ERROR") {
t.Fatalf("ship #%d was refused; separated repeats are normal work", i)
}
if out, _ := d(context.Background(), call("exec", `{"command":"go test ./..."}`)); strings.HasPrefix(out, "ERROR") {
t.Fatalf("exec #%d was refused", i)
}
}
if len(inner.calls) != 16 {
t.Errorf("%d calls ran, want all 16", len(inner.calls))
}
}
// Polling IS repetition: exec_monitor, agent_monitor and ask_user exist to be
// called again with the same arguments. Guarding them would break the only way
// dun waits for anything.
func TestLoopGuard_ExemptsPollingTools(t *testing.T) {
h := newNoteHarness(t)
inner := &counting{}
d := withLoopGuard(inner.dispatch, h)
for name := range pollingTools {
inner.calls = nil
for i := 0; i < 10; i++ {
if out, _ := d(context.Background(), call(name, `{"id":1}`)); strings.HasPrefix(out, "ERROR") {
t.Fatalf("%s poll #%d was refused", name, i)
}
}
if len(inner.calls) != 10 {
t.Errorf("%s: %d polls ran, want 10", name, len(inner.calls))
}
}
}
// A poll between two identical calls means something was being waited on, which
// is exactly the "the world changed in between" the run is looking for.
func TestLoopGuard_APollBreaksTheRun(t *testing.T) {
h := newNoteHarness(t)
inner := &counting{}
d := withLoopGuard(inner.dispatch, h)
for i := 0; i < 6; i++ {
d(context.Background(), call("exec", `{"command":"make"}`))
d(context.Background(), call("exec_monitor", `{"id":1}`))
}
if len(inner.calls) != 12 {
t.Errorf("%d calls ran, want 12 — a poll must reset the run", len(inner.calls))
}
}
// Key order is not meaning; whitespace inside a value is. The arguments being
// compared are shell commands and file contents, where collapsing a space would
// make two DIFFERENT calls look identical — the one failure mode a guard that
// refuses work must not have.
func TestLoopGuard_NormalizesKeyOrderButNotContent(t *testing.T) {
if a, b := normalizeToolArgs(`{"a":1,"b":2}`), normalizeToolArgs(`{"b":2,"a":1}`); a != b {
t.Errorf("key order should not matter: %q vs %q", a, b)
}
if a, b := normalizeToolArgs(`{"c":"x y"}`), normalizeToolArgs(`{"c":"x y"}`); a == b {
t.Error("whitespace inside a value is data and must not be normalized away")
}
// Non-JSON arguments still compare, just literally.
if got := normalizeToolArgs(" not json "); got != "not json" {
t.Errorf("non-JSON = %q, want it trimmed", got)
}
}
// A model that reads the refusal and repeats the call anyway is not going to be
// argued out of it. The human decides, once.
func TestLoopGuard_EscalatesToTheUserOnce(t *testing.T) {
h := newNoteHarness(t)
h.cfg.Ask = func(context.Context, string, []string, bool) (string, error) { return "", nil }
d := withLoopGuard((&counting{}).dispatch, h)
for i := 0; i < defaultLoopRepeats+loopAskAfter+4; i++ {
d(context.Background(), call("recap", `{"from":"x"}`))
}
forced := h.mergeForcedToolCalls(nil)
if len(forced) != 1 {
t.Fatalf("%d forced calls, want exactly 1", len(forced))
}
if forced[0].Function.Name != "ask_user" {
t.Errorf("forced %q, want ask_user", forced[0].Function.Name)
}
if !strings.Contains(forced[0].Function.Arguments, "not making progress") {
t.Errorf("the question should say what is wrong: %s", forced[0].Function.Arguments)
}
}
// Without an ask handler there is nobody to escalate TO, and forcing a call to a
// tool that is not in the tool set would be a call the model cannot see.
func TestLoopGuard_DoesNotEscalateWithNoAsker(t *testing.T) {
h := newNoteHarness(t)
d := withLoopGuard((&counting{}).dispatch, h)
for i := 0; i < defaultLoopRepeats+loopAskAfter+4; i++ {
d(context.Background(), call("recap", `{"from":"x"}`))
}
if forced := h.mergeForcedToolCalls(nil); len(forced) != 0 {
t.Errorf("%d forced calls with no AskFunc, want 0", len(forced))
}
}
// The escape hatch, for a workload this guard reads wrong.
func TestLoopGuard_CanBeDisabled(t *testing.T) {
t.Setenv("DUN_LOOP_REPEATS", "0")
h := newNoteHarness(t)
inner := &counting{}
d := withLoopGuard(inner.dispatch, h)
for i := 0; i < 20; i++ {
d(context.Background(), call("recap", `{"from":"x"}`))
}
if len(inner.calls) != 20 {
t.Errorf("%d calls ran with the guard disabled, want 20", len(inner.calls))
}
}
// A refusal must still carry anything buffered. The guard sits outside
// withLiftedQueue so it can see every tool, so it has to drain the queue itself
// — and a message the user typed while the agent looped is the last thing that
// should wait for the loop to end.
func TestLoopGuard_RefusalStillCarriesQueuedMessages(t *testing.T) {
h := newNoteHarness(t)
d := withLoopGuard((&counting{}).dispatch, h)
d(context.Background(), call("recap", `{"from":"x"}`))
h.Say("stop doing that, try the other file")
var out string
for i := 0; i < defaultLoopRepeats+2; i++ {
out, _ = d(context.Background(), call("recap", `{"from":"x"}`))
if strings.Contains(out, "NOT run") {
break // the FIRST refusal is the one the queue was waiting for
}
}
if !strings.Contains(out, "stop doing that") {
t.Errorf("the user's message was stranded behind the refusal:\n%s", out)
}
if !strings.Contains(out, "NOT run") {
t.Errorf("the refusal itself was lost:\n%s", out)
}
}
// The guard sits outermost, so a refusal is never reconsidered by an inner
// wrapper — and it must not disturb the result of a call it lets through.
func TestLoopGuard_IsTransparentWhenItDoesNotFire(t *testing.T) {
h := newNoteHarness(t)
inner := &counting{result: "the real result"}
d := withLoopGuard(inner.dispatch, h)
out, err := d(context.Background(), call("exec", `{"command":"ls"}`))
if err != nil || out != "the real result" {
t.Errorf("got (%q, %v), want the tool's own result untouched", out, err)
}
}