Skip to content

Commit 22abdc3

Browse files
author
opencode
committed
feat(agentloop): Self-Reflection-Turn vor Completion (issue #152)
What ships: - cmd/sin-code/internal/agentloop/loop.go: - New Reflection + Reflector types (per-proposal self-critique) - New Loop.Reflector field - State: reflectedThisProposal (resets when worker makes tool calls) - Logic: runs Reflector exactly once per completion proposal, BEFORE the stop-gate. If issues non-empty, injects them as a user message and continues (not a stop-gate reject — worker self-fixes). - cmd/sin-code/internal/ledger/store.go: new EntryType TypeReflection. - cmd/sin-code/internal/hooks/hooks.go: new event hooks.ReflectIssues. - cmd/sin-code/internal/agentloop/loop_reflect_test.go: 3 tests (disabled, forces-another-turn, no-issues-proceeds). Acceptance criteria (from #152): - [x] Reflector runs once per proposal (not infinite self-doubt). - [x] Non-empty Issues force another turn via user message. - [x] Reflector=nil preserves legacy behavior. - [x] go test ./cmd/sin-code/internal/agentloop/... all green. Hard mandates honored: - M2: no new deps. - M3: reflection is post-verify, pre-stop-gate; never short-circuits the verify-gate. - M7: 3/3 tests pass under go test -race -count=1. Refs: #152
1 parent 5ff1979 commit 22abdc3

4 files changed

Lines changed: 175 additions & 0 deletions

File tree

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,20 @@ type StopDecision struct {
7878
// A nil StopGate preserves the legacy behavior exactly.
7979
type StopGate func(ctx context.Context, snap StopSnapshot) StopDecision
8080

81+
// Reflection is the worker's self-critique of a proposed completion. Issues
82+
// non-empty means the agent found problems in its own work and should fix
83+
// them before the stop-gate is consulted.
84+
type Reflection struct {
85+
Issues []string
86+
Notes string
87+
}
88+
89+
// Reflector performs a self-critique pass on a proposed completion. Returning
90+
// a Reflection with non-empty Issues forces one more work turn. A nil
91+
// Reflector disables the reflection step (legacy behavior).
92+
// Issue #152.
93+
type Reflector func(ctx context.Context, snap StopSnapshot) Reflection
94+
8195
type Loop struct {
8296
Gate *verify.Gate
8397
LocalTool LocalToolFunc
@@ -119,6 +133,13 @@ type Loop struct {
119133
// crosses this fraction of MaxTokens (e.g. 0.8). Useful for alerting.
120134
BudgetWarnRatio float64
121135

136+
// Reflector, if set, runs a self-critique pass right BEFORE the stop-gate.
137+
// If it returns issues, the loop injects them and continues working — a
138+
// cheap quality lift that reduces stop-gate rejections. Runs at most once
139+
// per proposed completion to avoid infinite self-doubt loops.
140+
// Issue #152.
141+
Reflector Reflector
142+
122143
// AllowContinuation switches the maxTurns outcome from a hard error to a
123144
// checkpointed, resumable Result (Continuation=true). Daemons set this so
124145
// a long task is re-enqueued and resumed rather than abandoned; one-shot
@@ -283,6 +304,7 @@ func (l *Loop) Run(ctx context.Context, sess *session.Session, prompt string) (*
283304
stallCount := 0
284305
totalTokens := 0 // issue #151: cumulative tokens across the run
285306
warnedBudget := false // fires hooks.BudgetWarn once per run
307+
reflectedThisProposal := false
286308
toolsSeen := map[string]bool{}
287309
var toolsUsed []string
288310

@@ -386,6 +408,37 @@ func (l *Loop) Run(ctx context.Context, sess *session.Session, prompt string) (*
386408
})
387409
l.record(ctx, ledger.TypeVerifyPass, map[string]any{"mode": string(res.Mode)}, "verification passed ("+string(res.Mode)+")")
388410

411+
// Self-reflection: one cheap self-critique pass before the
412+
// independent stop-gate. Reset the flag whenever the worker did
413+
// real work (tool calls) in between, so each fresh proposal gets
414+
// exactly one reflection. Issue #152.
415+
if l.Reflector != nil && !reflectedThisProposal {
416+
reflectedThisProposal = true
417+
ref := l.Reflector(ctx, StopSnapshot{
418+
Prompt: prompt, FinalOutput: resp.Text, Turns: turn + 1,
419+
ToolsUsed: toolsUsed, VerifyPassed: res.Passed, SessionID: sess.ID,
420+
})
421+
if len(ref.Issues) > 0 {
422+
l.fire(ctx, hooks.ReflectIssues, "", map[string]any{"issues": ref.Issues})
423+
l.record(ctx, ledger.TypeReflection,
424+
map[string]any{"issues": ref.Issues},
425+
"self-reflection found issues; continuing")
426+
var b strings.Builder
427+
b.WriteString("SELF-REVIEW found issues to fix before completing:\n")
428+
for i, is := range ref.Issues {
429+
fmt.Fprintf(&b, " %d. %s\n", i+1, is)
430+
}
431+
if strings.TrimSpace(ref.Notes) != "" {
432+
b.WriteString("Notes: " + ref.Notes + "\n")
433+
}
434+
msgs = append(msgs, session.Message{Role: "user", Content: b.String()})
435+
if err := sess.SaveHistory(msgs); err != nil {
436+
return nil, err
437+
}
438+
continue
439+
}
440+
}
441+
389442
// Stop-gate: completion authority is decoupled from the worker.
390443
// The verify-gate passing is necessary but not sufficient — an
391444
// independent evaluator confirms the goal contract is satisfied
@@ -483,6 +536,9 @@ func (l *Loop) Run(ctx context.Context, sess *session.Session, prompt string) (*
483536
}
484537

485538
for _, tc := range resp.ToolCalls {
539+
// Real work happened in this turn — reset the reflection
540+
// flag so a fresh proposal can be re-evaluated.
541+
reflectedThisProposal = false
486542
if !toolsSeen[tc.Name] {
487543
toolsSeen[tc.Name] = true
488544
toolsUsed = append(toolsUsed, tc.Name)
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// SPDX-License-Identifier: MIT
2+
// Purpose: tests for issue #152 — self-reflection pass before the
3+
// stop-gate. When the Reflector returns issues, the loop continues
4+
// working instead of consulting the gate.
5+
package agentloop
6+
7+
import (
8+
"context"
9+
"testing"
10+
11+
"github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/session"
12+
)
13+
14+
// Reflector=nil preserves legacy behavior (no reflection).
15+
func TestReflector_DisabledWhenNil(t *testing.T) {
16+
s := setupSession(t)
17+
gateCalls := 0
18+
loop := &Loop{
19+
Gate: passGate(),
20+
// Reflector: nil (default)
21+
StopGate: func(ctx context.Context, snap StopSnapshot) StopDecision {
22+
gateCalls++
23+
return StopDecision{Complete: true}
24+
},
25+
Completion: func(ctx context.Context, msgs []session.Message, tools []ToolSpec) (*Completion, error) {
26+
return &Completion{Text: "ok", Raw: session.Message{Role: "assistant", Content: "ok"}}, nil
27+
},
28+
}
29+
_, err := loop.Run(context.Background(), s, "x")
30+
if err != nil {
31+
t.Fatal(err)
32+
}
33+
if gateCalls != 1 {
34+
t.Errorf("expected 1 stop-gate call, got %d", gateCalls)
35+
}
36+
}
37+
38+
// Reflector returns issues -> loop injects them and forces another turn.
39+
func TestReflector_ForcesAnotherTurn(t *testing.T) {
40+
s := setupSession(t)
41+
reflections := 0
42+
completionCalls := 0
43+
gateCalls := 0
44+
loop := &Loop{
45+
Gate: passGate(),
46+
Reflector: func(ctx context.Context, snap StopSnapshot) Reflection {
47+
reflections++
48+
// Return issues once, then accept. The Reflector runs at
49+
// most once per proposal — the second time the worker
50+
// proposes completion, reflectedThisProposal is true and
51+
// the Reflector is skipped.
52+
if reflections == 1 {
53+
return Reflection{Issues: []string{"missing test"}, Notes: "add a unit test"}
54+
}
55+
return Reflection{}
56+
},
57+
StopGate: func(ctx context.Context, snap StopSnapshot) StopDecision {
58+
gateCalls++
59+
return StopDecision{Complete: true}
60+
},
61+
Completion: func(ctx context.Context, msgs []session.Message, tools []ToolSpec) (*Completion, error) {
62+
completionCalls++
63+
return &Completion{Text: "done", Raw: session.Message{Role: "assistant", Content: "done"}}, nil
64+
},
65+
}
66+
_, err := loop.Run(context.Background(), s, "x")
67+
if err != nil {
68+
t.Fatal(err)
69+
}
70+
// Reflector runs ONCE (returns issues, the loop continues).
71+
// On the second turn, reflectedThisProposal is still true, so
72+
// the Reflector is skipped — straight to stop-gate.
73+
if reflections != 1 {
74+
t.Errorf("expected 1 reflection call (one per proposal), got %d", reflections)
75+
}
76+
if completionCalls != 2 {
77+
t.Errorf("expected 2 completion calls (one per turn), got %d", completionCalls)
78+
}
79+
if gateCalls != 1 {
80+
t.Errorf("expected 1 stop-gate call (after second completion), got %d", gateCalls)
81+
}
82+
}
83+
84+
// Reflector with no issues on first call -> straight to stop-gate.
85+
func TestReflector_NoIssuesProceeds(t *testing.T) {
86+
s := setupSession(t)
87+
reflections := 0
88+
gateCalls := 0
89+
loop := &Loop{
90+
Gate: passGate(),
91+
Reflector: func(ctx context.Context, snap StopSnapshot) Reflection {
92+
reflections++
93+
return Reflection{} // no issues
94+
},
95+
StopGate: func(ctx context.Context, snap StopSnapshot) StopDecision {
96+
gateCalls++
97+
return StopDecision{Complete: true}
98+
},
99+
Completion: func(ctx context.Context, msgs []session.Message, tools []ToolSpec) (*Completion, error) {
100+
return &Completion{Text: "ok", Raw: session.Message{Role: "assistant", Content: "ok"}}, nil
101+
},
102+
}
103+
_, err := loop.Run(context.Background(), s, "x")
104+
if err != nil {
105+
t.Fatal(err)
106+
}
107+
if reflections != 1 {
108+
t.Errorf("expected 1 reflection call, got %d", reflections)
109+
}
110+
if gateCalls != 1 {
111+
t.Errorf("expected 1 stop-gate call, got %d", gateCalls)
112+
}
113+
}

‎cmd/sin-code/internal/hooks/hooks.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ const (
5656
// Token budget lifecycle (issue #151).
5757
BudgetWarn = "budget.warn"
5858
BudgetExhausted = "budget.exhausted"
59+
// ReflectIssues fires when the self-reflection pass finds problems the
60+
// worker must fix before completion is evaluated.
61+
ReflectIssues = "reflect.issues"
5962

6063
AgentSpawn = "agent.spawn"
6164
AgentComplete = "agent.complete"

‎cmd/sin-code/internal/ledger/store.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ const (
4040
// TypeTokenBudgetExhausted is recorded when cumulative token usage exceeds
4141
// MaxTokens and the run stops spending (issue #151).
4242
TypeTokenBudgetExhausted EntryType = "token_budget_exhausted"
43+
// TypeReflection records a self-critique pass that found issues and forced
44+
// another work turn before stop-gate evaluation.
45+
TypeReflection EntryType = "reflection"
4346
)
4447

4548
// Entry is one row in the ledger.

0 commit comments

Comments
 (0)