mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 13:11:31 -10:00
* fix(agent): don't end a run as success on a no-tool-call turn mid-task
A turn that produced text but no tool call was always accepted as the final answer, so the loop reported success even when the model stopped mid-task (e.g. ended on "...Let me check the SSH configuration:" with plan steps still pending).
Add an opt-in completion gate (Options.RequireCompletionSignal): when a turn has no tool call and work clearly remains -- pending update_plan items, or the message ends on a continuation cue -- re-prompt the model to continue instead of finalizing. Bounded by maxContinueNudges and still by MaxTurns/the deadline; once the budget is spent the run finalizes as INCOMPLETE (Result.Incomplete) rather than success. Default off, so the interactive path is byte-identical.
Genuine single-turn completions (no pending plan, no cue) still finalize as success. Covered by internal/agent/completion_gate_test.go.
* feat(exec): report stalled headless runs as INCOMPLETE (exit 4)
Enable the agent completion gate for headless exec (RequireCompletionSignal) and map Result.Incomplete to run_end status "incomplete" with a new exit code 4, so a run that stalled mid-task (model stopped without a tool call while work remained, continue budget exhausted) is no longer reported as success. Interactive callers are unaffected.
* fix(agent): downgrade self-reported non-completion; advisory task-grounded acceptance
Reduce -- not eliminate -- false-success on headless runs. Two DETERMINISTIC, unit-tested gates (with the plan gate from the prior commit):
(a) self-report downgrade: if the final message admits the model guessed or could not meet the objective, finalize INCOMPLETE (exit 4), never success. Inability is matched by first-person STEMS generalized over verb/tense ("I cannot/can't/could not/am unable to/do not have/unable to ...") plus guess/fallback/uncertainty phrases, with a guard so success-y negations ("could not find any issues", "cannot reproduce") are not misread. (b7bc0b8's plan gate already forces INCOMPLETE on pending/in_progress update_plan items at termination.)
(b) task-grounded acceptance is ADVISORY, not a guarantee. When --self-correct is on it demands one bounded acceptance pass that re-derives the task's stated criterion and runs a concrete check, discouraging three false-success patterns (well-formed==correct, existing-tests-pass==objective-met, result==baseline-it-was-told-to-beat). But it is a prompt: a model that ignores it and confidently claims "PASS, all requirements met" still slips, because ZERO has no general oracle to verify correctness against a task's hidden criterion. Empirically (TB-2, qwen3-coder:480b) this reliably catches admissions and incomplete plans and REDUCES false-success, but a confident false PASS on a model-ceiling task is a residual, fundamental gap -- not a tuning miss.
Default off (RequireCompletionSignal); interactive callers are byte-identical. Covered by internal/agent/{acceptance_gate_test.go,completion_gate_test.go}.
* feat(exec): surface the INCOMPLETE reason in run_end and logs
When a headless run finalizes as INCOMPLETE, include Result.IncompleteReason in the session error event and a stderr warning so an honestly-incomplete run (e.g. "the final message admits the objective was not met") is debuggable rather than an opaque exit 4.
* fix(agent): harden completion gate per PR review
Addresses the correctness findings from the PR review.
self-report (#1): drop behavior-describing phrases ("fall back to",
"placeholder value", bare "best guess", "as a fallback", "without proper")
that also match legitimate final answers; keep first-person/uncertainty
admissions only, since these are matched without a context guard.
self-report (#5): scan every occurrence of each inability stem so an early
success-negation ("could not find any examples") no longer masks a later
genuine admission with the same stem ("could not implement it").
continuation cue (#6): require a trailing colon AND an action lead-in on the
final clause; stop flagging recommendations, plain summary colons, and
sign-offs. Still catches the mid-line "...Let me check the config:".
gate order (#8): check the self-report admission BEFORE pending-plan, so an
admitted-impossible task downgrades immediately with the accurate reason
instead of burning continue-nudges.
pending plan (#3): treat a pending/in_progress update_plan item as a
NUDGE-only weak signal -- it no longer forces INCOMPLETE on its own (a
completed run that left stale plan bookkeeping is trusted). Only a
continuation cue or a self-report admission finalizes INCOMPLETE.
max-turns (#4): a run cut off at the MaxTurns ceiling now finalizes
INCOMPLETE under the gate instead of being reported as success.
exec json/cron (#2, #7): for -o json, emit the terminal done with exit 4 on
an incomplete run (final() pre-emits a success done:0 for json that would
otherwise mask it); emit an error event -- not just a warning -- so the cron
failure extractor can recover the reason.
Deferred (noted on the PR): acceptance-only-when-mutated (#9, cost) and
reusing tools.normalizePlanStatus (#10, would widen scope to internal/tools).
Tests: add TestContinuationCueMatching and TestMaxTurnsCutoffIsIncompleteUnderGate,
extend TestSelfReportedIncompletionMatching with the #1/#5 cases, and replace
the in_progress=>incomplete test with TestPendingPlanAloneDoesNotForceIncomplete.
make build / go vet / make lint / go test ./... -race all green.
206 lines
7.8 KiB
Go
206 lines
7.8 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
)
|
|
|
|
// someRequestContains reports whether any message sent to the provider across all
|
|
// turns contains substr — used to assert a continue nudge was actually injected.
|
|
func someRequestContains(requests []zeroruntime.CompletionRequest, substr string) bool {
|
|
for _, req := range requests {
|
|
for _, msg := range req.Messages {
|
|
if strings.Contains(msg.Content, substr) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// planTurn is a turn that calls update_plan with the given item statuses (reusing
|
|
// the package's shared toolTurn helper).
|
|
func planTurn(statuses ...string) []zeroruntime.StreamEvent {
|
|
items := make([]string, len(statuses))
|
|
for i, s := range statuses {
|
|
items[i] = `{"content":"step ` + s + `","status":"` + s + `"}`
|
|
}
|
|
return toolTurn("plan", "update_plan", `{"plan":[`+strings.Join(items, ",")+`]}`)
|
|
}
|
|
|
|
// BUG #1 regression: a no-tool-call turn that ends mid-step while plan items are
|
|
// still pending must NOT be accepted as success. The loop must re-prompt (bounded)
|
|
// and, if the model keeps stalling, finalize as INCOMPLETE — never success.
|
|
func TestCompletionGatePendingPlanContinuesThenIncomplete(t *testing.T) {
|
|
// Mirrors the git-multibranch failure: plan with pending steps, then the model
|
|
// keeps emitting "…Let me check the SSH configuration:" without acting.
|
|
cue := "Now I need to configure the SSH server. Let me check the current SSH configuration:"
|
|
registry := tools.NewRegistry()
|
|
registry.Register(tools.NewUpdatePlanTool())
|
|
|
|
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
|
planTurn("completed", "pending", "pending"),
|
|
textTurn(cue), textTurn(cue), textTurn(cue), textTurn(cue), textTurn(cue),
|
|
}}
|
|
|
|
result, err := Run(context.Background(), "set up a git server", provider, Options{
|
|
Registry: registry,
|
|
MaxTurns: 10,
|
|
RequireCompletionSignal: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !result.Incomplete {
|
|
t.Fatalf("expected Incomplete=true (model stalled with pending plan), got false; final=%q turns=%d", result.FinalAnswer, result.Turns)
|
|
}
|
|
// 1 plan turn + maxContinueNudges(3) nudged turns + 1 final stalling turn = 5.
|
|
// Critically it did NOT stop at the first text turn (request 2) as success.
|
|
if len(provider.requests) != 1+maxContinueNudges+1 {
|
|
t.Fatalf("expected %d provider turns (1 plan + %d nudges + 1 final), got %d",
|
|
1+maxContinueNudges+1, maxContinueNudges, len(provider.requests))
|
|
}
|
|
if !someRequestContains(provider.requests, continueNudgeMarker) {
|
|
t.Fatalf("expected a continue nudge (%q) to be injected into the conversation", continueNudgeMarker)
|
|
}
|
|
}
|
|
|
|
// A genuinely-complete single-turn answer (no plan, no continuation cue) must
|
|
// still finalize as success — the gate must not break short/read-only tasks.
|
|
func TestCompletionGateAcceptsGenuineCompletion(t *testing.T) {
|
|
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
|
textTurn("The file contains 42 lines."),
|
|
}}
|
|
|
|
result, err := Run(context.Background(), "count the lines", provider, Options{
|
|
Registry: tools.NewRegistry(),
|
|
MaxTurns: 10,
|
|
RequireCompletionSignal: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Incomplete {
|
|
t.Fatalf("genuine completion wrongly marked Incomplete; final=%q", result.FinalAnswer)
|
|
}
|
|
if result.FinalAnswer != "The file contains 42 lines." {
|
|
t.Fatalf("final answer = %q, want the completed answer", result.FinalAnswer)
|
|
}
|
|
if len(provider.requests) != 1 {
|
|
t.Fatalf("expected exactly 1 turn (no spurious re-prompt), got %d", len(provider.requests))
|
|
}
|
|
}
|
|
|
|
// A continuation-cue turn triggers a re-prompt, but once the model actually
|
|
// finishes (clean answer, no cue, no pending plan) the run exits as success — the
|
|
// nudge gives the model a path to a legitimate completion.
|
|
func TestCompletionGateContinuesOnCueThenSucceeds(t *testing.T) {
|
|
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
|
textTurn("Let me read the file:"),
|
|
textTurn("Done. The file has 42 lines."),
|
|
}}
|
|
|
|
result, err := Run(context.Background(), "count the lines", provider, Options{
|
|
Registry: tools.NewRegistry(),
|
|
MaxTurns: 10,
|
|
RequireCompletionSignal: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Incomplete {
|
|
t.Fatalf("run wrongly marked Incomplete after the model completed; final=%q", result.FinalAnswer)
|
|
}
|
|
if result.FinalAnswer != "Done. The file has 42 lines." {
|
|
t.Fatalf("final answer = %q, want the completed answer", result.FinalAnswer)
|
|
}
|
|
if len(provider.requests) != 2 {
|
|
t.Fatalf("expected 2 turns (1 cue re-prompted + 1 completion), got %d", len(provider.requests))
|
|
}
|
|
if !someRequestContains(provider.requests, continueNudgeMarker) {
|
|
t.Fatalf("expected a continue nudge after the continuation-cue turn")
|
|
}
|
|
}
|
|
|
|
// With the gate OFF (the interactive/TUI default), a continuation-cue turn is
|
|
// accepted as the final answer exactly as before — guaranteeing no behavior
|
|
// change for non-headless callers.
|
|
func TestCompletionGateOffPreservesLegacyBehavior(t *testing.T) {
|
|
cue := "Let me check the config:"
|
|
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
|
textTurn(cue),
|
|
}}
|
|
|
|
result, err := Run(context.Background(), "do a thing", provider, Options{
|
|
Registry: tools.NewRegistry(),
|
|
MaxTurns: 10,
|
|
// RequireCompletionSignal deliberately left false.
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Incomplete {
|
|
t.Fatalf("legacy path must never set Incomplete; final=%q", result.FinalAnswer)
|
|
}
|
|
if result.FinalAnswer != cue {
|
|
t.Fatalf("final answer = %q, want %q (legacy: text-only turn is the answer)", result.FinalAnswer, cue)
|
|
}
|
|
if len(provider.requests) != 1 {
|
|
t.Fatalf("legacy path must not re-prompt; got %d turns", len(provider.requests))
|
|
}
|
|
}
|
|
|
|
// review #6: the continuation-cue detector must catch a mid-line action announcement
|
|
// that stops on a colon (the git-multibranch case), without flagging genuine closers
|
|
// — a recommendation, a plain summary colon, or a sign-off.
|
|
func TestContinuationCueMatching(t *testing.T) {
|
|
cases := []struct {
|
|
text string
|
|
cue bool
|
|
}{
|
|
{"Now I need to configure the SSH server. Let me check the current SSH configuration:", true},
|
|
{"Let me read the file:", true},
|
|
{"Now I'll run the tests:", true},
|
|
{"Next, I suggest reviewing the changes.", false}, // recommendation, no colon
|
|
{"Here is the summary:", false}, // summary colon, no action lead-in
|
|
{"Let me know if you need anything:", false}, // sign-off
|
|
{"The function is implemented and all tests pass.", false},
|
|
}
|
|
for _, c := range cases {
|
|
if got := endsWithContinuationCue(c.text); got != c.cue {
|
|
t.Errorf("endsWithContinuationCue(%q) = %v, want %v", c.text, got, c.cue)
|
|
}
|
|
}
|
|
}
|
|
|
|
// review #4: a run that loops to the MaxTurns ceiling (always calling a tool, so it
|
|
// never reaches the no-tool-call gate) was reported as success. Under the headless
|
|
// gate, a max-turns cutoff is INCOMPLETE — the agent was stopped mid-run, not done.
|
|
func TestMaxTurnsCutoffIsIncompleteUnderGate(t *testing.T) {
|
|
registry := tools.NewRegistry()
|
|
registry.Register(tools.NewUpdatePlanTool())
|
|
toolEvery := toolTurn("c", "update_plan", `{"plan":[{"content":"step","status":"in_progress"}]}`)
|
|
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
|
toolEvery, toolEvery, toolEvery, toolEvery,
|
|
}}
|
|
|
|
result, err := Run(context.Background(), "keep working", provider, Options{
|
|
Registry: registry,
|
|
MaxTurns: 2,
|
|
RequireCompletionSignal: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !result.Incomplete {
|
|
t.Fatalf("a max-turns cutoff under the gate must be Incomplete; final=%q", result.FinalAnswer)
|
|
}
|
|
if !strings.Contains(result.IncompleteReason, "max-turns") {
|
|
t.Fatalf("IncompleteReason = %q, want it to cite max-turns", result.IncompleteReason)
|
|
}
|
|
}
|