Skip to content

Commit c339518

Browse files
author
SIN CI
committed
merge: unify chat sin_edit with MCP (#373) + build fixes for all 6 merges
2 parents 9e60b1d + 0f7ae94 commit c339518

14 files changed

Lines changed: 632 additions & 332 deletions

‎cmd/sin-code/internal/agentloop/loop.go‎

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -235,10 +235,12 @@ type Loop struct {
235235
CoverageForbiddenTools []string
236236

237237
// ResultPolicy, if set, scans every tool result for secret leakage,
238-
// destructive operations, and network egress. It can block subsequent
239-
// writes or require confirmation for the next destructive tool call
240-
// (issue #374). Optional — nil preserves legacy behavior.
241-
ResultPolicy *permission.ResultPolicyStore
238+
// destructive operations, and network egress (issue #374).
239+
ResultPolicy *permission.ResultPolicy
240+
241+
// Observer, when set, observes tool calls via Observe/LastTrip
242+
// for full fingerprint-based loop detection (issue #377).
243+
Observer *LoopDetector
242244

243245
// RunOverride, if set, replaces the default Run. Used by the
244246
// WebUI v2 chat API (issue #52) so tests can swap in a
@@ -252,12 +254,6 @@ type Loop struct {
252254
// == "poc" (issue #290).
253255
TournamentRunner TournamentRunner
254256

255-
// ResultPolicy, if set, scans the string returned by every executed
256-
// tool and surfaces warnings/escalations for secret leakage,
257-
// destructive confirmations, or network egress markers (issue #374).
258-
// Optional — nil preserves exact legacy behavior.
259-
ResultPolicy *permission.ResultPolicy
260-
261257
// Frustration, when set, tracks user message patterns for frustration
262258
// signals and appends a system-prompt suffix when detected (issue #271).
263259
// Optional — nil preserves legacy behavior.
@@ -1235,9 +1231,9 @@ func (l *Loop) Run(ctx context.Context, sess *session.Session, prompt string) (*
12351231
// TOOL REFUSED message and skip execute() so the model
12361232
// gets feedback AND the dispatch site never reaches a
12371233
// destructive mutator while the worker is thrashing.
1238-
if l.Observer != nil && l.Observer.Enabled() {
1239-
if oerr := l.Observer.Observe(tc, ""); oerr != nil {
1240-
trip := l.Observer.LastTrip()
1234+
if l.LoopDetector != nil && l.LoopDetector.Enabled() {
1235+
if oerr := l.LoopDetector.Observe(tc, ""); oerr != nil {
1236+
trip := l.LoopDetector.LastTrip()
12411237
data := map[string]any{"reason": "loop.detected"}
12421238
if trip != nil {
12431239
data["pattern_length"] = trip.Length

‎cmd/sin-code/internal/agentloop/loop_detector.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@ func NewSimpleLoopDetector(maxRepeats, windowSize int) *LoopDetector {
1616
if windowSize < 1 {
1717
windowSize = 1
1818
}
19+
w := maxRepeats * windowSize
20+
if w < maxRepeats {
21+
w = maxRepeats
22+
}
1923
return &LoopDetector{
20-
Window: windowSize,
24+
Window: w,
2125
MinPatternLength: 1,
2226
MinRepeats: maxRepeats,
2327
}

‎cmd/sin-code/internal/agentloop/loop_detector_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import (
99

1010
func TestNewLoopDetector_Defaults(t *testing.T) {
1111
d := NewSimpleLoopDetector(0, 0)
12-
if d.maxRepeats != 1 || d.windowSize != 1 {
13-
t.Fatalf("clamped values = (%d,%d), want (1,1)", d.maxRepeats, d.windowSize)
12+
if d.MinRepeats != 1 || d.Window != 1 {
13+
t.Fatalf("clamped values = (%d,%d), want (1,1)", d.MinRepeats, d.Window)
1414
}
1515
if d.IsLooping() {
1616
t.Fatal("empty detector should not loop")

‎cmd/sin-code/internal/agentloop/loop_observer.go‎

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -168,40 +168,44 @@ func (d *LoopDetector) Observe(tc ToolCall, messageHash string) error {
168168
return d.evaluateLocked()
169169
}
170170

171-
// evaluateLocked re-scans the history for the longest qualifying
172-
// repetition and trips the detector on the first match. Caller holds
173-
// d.mu.
171+
// evaluateLocked re-scans the history for qualifying repetitions and
172+
// trips the detector on the first match. It checks all pattern lengths
173+
// from longest to shortest so that a longer partial-repeat does not
174+
// mask a shorter full-repeat. Caller holds d.mu.
174175
func (d *LoopDetector) evaluateLocked() error {
175176
n := len(d.history)
176177
if n < d.MinPatternLength*d.MinRepeats {
177178
return nil
178179
}
179-
p := longestMatchingPrefix(d.history)
180-
if p < d.MinPatternLength {
181-
return nil
182-
}
183-
repeats := n / p
184-
if repeats < d.MinRepeats {
185-
return nil
186-
}
187-
pattern := make([]string, p)
188-
for i := 0; i < p; i++ {
189-
pattern[i] = d.history[n-p+i].Key
190-
}
191-
d.tripped = true
192-
d.pattern = pattern
193-
name := d.history[n-1].Name
194-
if name == "" {
195-
name = "unknown"
196-
}
197-
d.trip = &LoopTrip{
198-
ToolName: name,
199-
Length: p,
200-
Repeats: repeats,
201-
Key: d.history[n-1].Key,
202-
HistoryLen: n,
180+
upper := n / 2
181+
for p := upper; p >= d.MinPatternLength; p-- {
182+
if !equalAt(d.history, n-p, n-2*p, p) {
183+
continue
184+
}
185+
repeats := n / p
186+
if repeats < d.MinRepeats {
187+
continue
188+
}
189+
pattern := make([]string, p)
190+
for i := 0; i < p; i++ {
191+
pattern[i] = d.history[n-p+i].Key
192+
}
193+
d.tripped = true
194+
d.pattern = pattern
195+
name := d.history[n-1].Name
196+
if name == "" {
197+
name = "unknown"
198+
}
199+
d.trip = &LoopTrip{
200+
ToolName: name,
201+
Length: p,
202+
Repeats: repeats,
203+
Key: d.history[n-1].Key,
204+
HistoryLen: n,
205+
}
206+
return ErrLoopDetected
203207
}
204-
return ErrLoopDetected
208+
return nil
205209
}
206210

207211
// Reset clears the detector state so a new run / session starts

‎cmd/sin-code/internal/agentloop/per_turn_budget_test.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@ import (
99

1010
func TestNewPerTurnBudget_Defaults(t *testing.T) {
1111
b := NewPerTurnBudget(1000, 2000)
12-
if b.ThinkingTokens != 1000 {
13-
t.Fatalf("ThinkingTokens = %d, want 1000", b.ThinkingTokens)
12+
if b.ThinkingRemaining() != 1000 {
13+
t.Fatalf("ThinkingRemaining = %d, want 1000", b.ThinkingRemaining())
1414
}
15-
if b.TokenTokens != 2000 {
16-
t.Fatalf("TokenTokens = %d, want 2000", b.TokenTokens)
15+
if b.TokensRemaining() != 2000 {
16+
t.Fatalf("TokensRemaining = %d, want 2000", b.TokensRemaining())
1717
}
1818
th, tk := b.Stats()
1919
if th != 0 || tk != 0 {

‎cmd/sin-code/internal/agentloop/session_context_loop_test.go‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,24 @@ import (
1313
"github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/verify"
1414
)
1515

16+
type mockTodoReader struct {
17+
items []string
18+
}
19+
20+
func (m *mockTodoReader) Open(blockedOnly bool) ([]string, error) { return m.items, nil }
21+
22+
type mockSessionSummaryReader struct {
23+
summary string
24+
}
25+
26+
func (m *mockSessionSummaryReader) Summary(sessionID string) (string, error) { return m.summary, nil }
27+
28+
type mockAutoMemoryReader struct {
29+
data []byte
30+
}
31+
32+
func (m *mockAutoMemoryReader) IndexBytes() ([]byte, error) { return m.data, nil }
33+
1634
func TestSessionContextPrependedOnNewSession(t *testing.T) {
1735
var first []session.Message
1836
captured := false

0 commit comments

Comments
 (0)