feat(control): finish what a turn owes instead of asking the user to say "continue"

A turn that ends owing verification has not failed. The host read what is
missing off its own receipts — not off anything the model claimed — and then
stopped to show a card whose only action was "continue". That asked the user
to relay a message the host had already written.

Goal mode has auto-continued on exactly this signal all along; the difference
was a goal being set. It no longer is: an ordinary turn that ends with unmet
requirements runs them as further turns, with the missing list as the prompt.
Nothing new judges intent — readiness is the same host-computed check, and a
turn that changed nothing owes nothing, so questions and reads still end in
one turn.

What bounds it is the gap itself. Each round is admitted only while the list
of missing requirements is still shrinking, measured against the best round so
far so an oscillating gap cannot reset the counter. Two rounds that owe what
the last one owed hand the turn back, card and all.

The continuation inherits the finished turn's ledger through the same
authorization the recovery card used. Without it the next run would start
empty, where a turn that owed verification owes nothing — the gap would read
as closed because the record of it was dropped, which is worse than not
continuing at all.

PrepareReadinessContinuation lands in final_readiness.go rather than agent.go,
which is over its size budget.
This commit is contained in:
esengine committed 2026-08-14 23:38:40 -07:00
1 parent 346a258559
commit e4e6936b1d
10 files changed
+217 -28

No files matched your search

-17
View File
@@ -1333,23 +1333,6 @@ func (a *Agent) PrepareDeliveryRecovery() bool {
return a.prepareEvidenceContinuation()
}
// PrepareReadinessContinuation is the same authorization for a continuation the
// host runs itself rather than one the user asked for. Without it the next run
// starts from an empty ledger, where a turn that owed verification owes
// nothing: the gap would read as closed because the record of it was dropped.
func (a *Agent) PrepareReadinessContinuation() bool {
return a.prepareEvidenceContinuation()
}
func (a *Agent) prepareEvidenceContinuation() bool {
if !a.pending.deliveryRecovery {
return false
}
a.pending.preserveEvidence = true
a.pending.deliveryRecovery = false
return true
}
func (a *Agent) updateDeliveryCheckpoint(runErr error) {
if !a.turn.deliveryScopeActive || a.task.scopeID == "" || a.task.ledger == nil {
return
+17
View File
@@ -239,3 +239,20 @@ func finalReadinessIncompleteTodos(items []evidence.TodoStepMatch) string {
}
return "latest successful todo_write still has incomplete items: " + strings.Join(parts, ", ")
}
// PrepareReadinessContinuation is the same authorization for a continuation the
// host runs itself rather than one the user asked for. Without it the next run
// starts from an empty ledger, where a turn that owed verification owes
// nothing: the gap would read as closed because the record of it was dropped.
func (a *Agent) PrepareReadinessContinuation() bool {
return a.prepareEvidenceContinuation()
}
func (a *Agent) prepareEvidenceContinuation() bool {
if !a.pending.deliveryRecovery {
return false
}
a.pending.preserveEvidence = true
a.pending.deliveryRecovery = false
return true
}
+10 -6
View File
@@ -661,10 +661,11 @@ func TestGoalDeliveryWorkflowCompletesAfterVerifiedSignoff(t *testing.T) {
}
}
// TestPlainDeliveryReadinessFailureSurfacesRecoveryCardWithoutRetries covers
// the plain (non-Goal) Delivery combination: readiness failure ends the run on
// the first final answer, surfaces the recovery card, and never auto-continues.
func TestPlainDeliveryReadinessFailureSurfacesRecoveryCardWithoutRetries(t *testing.T) {
// TestPlainDeliveryReadinessFailureStillSurfacesRecoveryCard covers the plain
// (non-Goal) Delivery combination. The host now finishes what the turn owes
// instead of stopping at the first premature final — but the continuations are
// bounded, and a gap that outlives them still reaches the user as the card.
func TestPlainDeliveryReadinessFailureStillSurfacesRecoveryCard(t *testing.T) {
todoWrite, _ := tool.LookupBuiltin("todo_write")
reg := tool.NewRegistry()
reg.Add(todoWrite)
@@ -692,8 +693,11 @@ func TestPlainDeliveryReadinessFailureSurfacesRecoveryCardWithoutRetries(t *test
if ev.Readiness == nil || len(ev.Readiness.Missing) == 0 {
t.Fatalf("TurnDone.Readiness = %+v, want missing requirements for the recovery card", ev.Readiness)
}
if prov.call != 3 {
t.Fatalf("provider calls = %d, want 3 (work turn + final answer, no readiness retries)", prov.call)
if prov.call < 4 {
t.Fatalf("provider calls = %d, want the host to continue past the premature final", prov.call)
}
if prov.call > 8 {
t.Fatalf("provider calls = %d, want the stall guard to bound the continuations", prov.call)
}
if got := c.GoalStatus(); got != GoalStatusStopped {
t.Fatalf("GoalStatus() = %q, want stopped (no goal involved)", got)
@@ -0,0 +1,87 @@
package control
import (
"context"
"errors"
"fmt"
"strings"
"reasonix/internal/agent"
"reasonix/internal/evidence"
"reasonix/internal/i18n"
)
// A turn that ends owing verification has not failed: the host read what is
// missing off its own receipts, so asking the user to press continue asks them
// to relay a message the host wrote. What bounds the work instead is the gap —
// admitted only while it is still shrinking.
const readinessStallRounds = 2
// continueUntilReady runs the missing requirements as further turns. turnErr is
// the outcome of the turn just finished; the returned error is the outcome of
// the last one run, so a caller cannot tell whether one turn or four produced it.
func (o *turnOrchestrator) continueUntilReady(ctx context.Context, turnErr error) error {
best, stall := -1, 0
for {
var readinessErr *agent.FinalReadinessError
if !errors.As(turnErr, &readinessErr) {
// Ready, or a failure that has nothing to do with readiness.
return turnErr
}
gap := len(readinessErr.Missing)
switch {
case best < 0 || gap < best:
best, stall = gap, 0
default:
// Compared against the best round so far, not the last one, so a gap
// that oscillates cannot reset the counter and run forever.
stall++
if stall >= readinessStallRounds {
return turnErr
}
}
if err := ctx.Err(); err != nil {
return err
}
prompt := readinessContinuationPrompt(o.c.goalTodos(), readinessErr.Reason)
if prompt == "" {
return turnErr
}
// The continuation inherits the finished turn's receipts. Without that
// the next run starts from an empty ledger and the gap reads as closed
// because the record of it was dropped, not because it was filled.
if o.c.executor == nil || !o.c.executor.PrepareReadinessContinuation() {
return turnErr
}
o.c.noticeDetail(i18n.M.ReadinessContinuing, prompt)
turnErr = o.runOrchestratedTurn(ctx, orchestratedTurn{input: prompt, raw: prompt, synthetic: true})
}
}
// readinessContinuationPrompt states what the turn still owes. It is the host's
// own account, so it names the requirement rather than asking the model whether
// it agrees one is outstanding.
func readinessContinuationPrompt(todos []evidence.TodoItem, reason string) string {
var parts []string
if incomplete := evidence.IncompleteTodos(todos); len(incomplete) > 0 {
var b strings.Builder
b.WriteString("these tasks are still incomplete:")
for _, t := range incomplete {
fmt.Fprintf(&b, "\n - %s (%s)", t.Content, t.Status)
}
parts = append(parts, b.String())
}
if reason = strings.TrimSpace(reason); reason != "" {
parts = append(parts, reason)
}
if len(parts) == 0 {
return ""
}
var b strings.Builder
b.WriteString("This turn ended with work still outstanding:\n")
for _, p := range parts {
b.WriteString("- " + p + "\n")
}
b.WriteString("Finish it now. Do the remaining work, then verify it and record the outcome.")
return b.String()
}
@@ -0,0 +1,86 @@
package control
import (
"testing"
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
// readinessDeliveryController wires a Delivery agent to a scripted provider, so
// the ledger the readiness check reads is the real one the turns produced.
func readinessDeliveryController(t *testing.T, prov provider.Provider) (*Controller, chan event.Event) {
t.Helper()
todoWrite, _ := tool.LookupBuiltin("todo_write")
reg := tool.NewRegistry()
reg.Add(todoWrite)
reg.Add(minimalFakeTool{name: "write_file"})
reg.Add(minimalFakeTool{name: "bash"})
ag := agent.New(prov, reg, agent.NewSession(""), agent.Options{DeliveryProfile: true}, event.Discard)
done := make(chan event.Event, 4)
c := New(Options{
Runner: ag, Executor: ag,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.TurnDone {
done <- e
}
}),
})
t.Cleanup(c.Close)
return c, done
}
// The host read the missing requirements off its own receipts. Handing the user
// a card that only says "continue" asks them to relay a message the host wrote,
// so the work continues on its own instead.
func TestOrdinaryTurnFinishesWhatItOwes(t *testing.T) {
prov := &scriptedTurns{turns: [][]provider.Chunk{
{toolCallChunk("t0", "todo_write", `{"todos":[{"content":"Ship main","status":"in_progress"}]}`), {Type: provider.ChunkDone}},
{toolCallChunk("w1", "write_file", `{"path":"main.go"}`), {Type: provider.ChunkDone}},
textTurn("premature final"),
// The continuation does the work the host said was missing.
{toolCallChunk("b1", "bash", `{"command":"go test ./..."}`), {Type: provider.ChunkDone}},
{toolCallChunk("t1", "todo_write", `{"todos":[{"content":"Ship main","status":"completed"}]}`), {Type: provider.ChunkDone}},
{toolCallChunk("c1", "complete_step", `{"step":"Ship main","result":"shipped","evidence":[{"kind":"verification","summary":"go test","command":"go test ./..."}]}`), {Type: provider.ChunkDone}},
textTurn("done and verified"),
}}
c, done := readinessDeliveryController(t, prov)
c.Submit("implement main")
ev := <-done
if prov.call <= 3 {
t.Fatalf("provider calls = %d, want the turn to continue past its own premature final", prov.call)
}
// The continuation inherits the finished turn's receipts: a run starting
// from an empty ledger would owe nothing at all, and the gap would read as
// closed because the record of it was dropped.
if ev.Readiness != nil && len(ev.Readiness.Missing) == 0 {
t.Fatal("the continuation reported an empty gap, which is what a dropped ledger looks like")
}
}
// A round that owes what the last one owed is not progress. Two of those hand
// the turn back rather than repeating forever.
func TestStalledReadinessStopsInsteadOfLooping(t *testing.T) {
prov := &scriptedTurns{turns: [][]provider.Chunk{
{toolCallChunk("t0", "todo_write", `{"todos":[{"content":"Ship main","status":"in_progress"}]}`), {Type: provider.ChunkDone}},
{toolCallChunk("w1", "write_file", `{"path":"main.go"}`), {Type: provider.ChunkDone}},
// Every later turn answers without doing anything, so the gap never moves.
textTurn("still not verified"),
}}
c, done := readinessDeliveryController(t, prov)
c.Submit("implement main")
ev := <-done
if ev.Readiness == nil || len(ev.Readiness.Missing) == 0 {
t.Fatalf("TurnDone.Readiness = %+v, want the unmet requirements handed back", ev.Readiness)
}
// One work turn, one premature final, then the bounded continuations.
if prov.call > 6 {
t.Fatalf("provider calls = %d, want the stall to stop the continuations", prov.call)
}
}
+8 -5
View File
@@ -434,14 +434,17 @@ func (o *turnOrchestrator) runTurnLoopWithPreparedTurn(ctx context.Context, turn
o.c.stopGoal(GoalStatusStopped)
return err
}
if !goalTurnErrorAbsorbable(err) || !o.c.goals.active() {
// Terminal provider/host error (or a plain non-Goal Delivery
// readiness failure): stop auto-continue. With no active Goal the
// error surfaces the recovery card; with a Goal it stays running so
// the next ordinary user message keeps the same scope.
if !goalTurnErrorAbsorbable(err) {
// Terminal provider/host error: stop auto-continue.
o.c.goalUsageTee.setActiveRecorder(nil)
return err
}
if !o.c.goals.active() {
// No Goal, but the host knows what the turn still owes. Finish it
// rather than handing the user a card that only says "continue".
o.c.goalUsageTee.setActiveRecorder(nil)
return o.continueUntilReady(ctx, err)
}
// FinalReadinessError is absorbed below: the Goal FSM continues with
// the missing requirements as the next turn's prompt.
}
+3
View File
@@ -193,6 +193,9 @@ type Messages struct {
CompactionChangesKept string
CompactionRepaired string
// shown when a turn ends owing requirements and the host runs them itself
ReadinessContinuing string
// extension structured-UI surfaces (ExtensionSurface / ExtensionStatus events).
ExtFormFieldsHint string // form card: field values are collected through the usual prompts
ExtRunActionFmt string // card action hint, one %s = the /<plugin>:<action> slash name
+2
View File
@@ -170,6 +170,8 @@ var English = Messages{
CompactionChangesKept: "changes kept",
CompactionRepaired: "repaired",
ReadinessContinuing: "finishing what this turn still owes",
ExtFormFieldsHint: "fields are collected through the usual prompts",
ExtRunActionFmt: "run %s",
SlashCompactFailed: "compaction failed",
+2
View File
@@ -171,6 +171,8 @@ var Chinese = Messages{
CompactionChangesKept: "处改动已保留",
CompactionRepaired: "已补救",
ReadinessContinuing: "这一轮还欠东西,继续把它做完",
ExtFormFieldsHint: "字段将通过逐项提问收集",
ExtRunActionFmt: "运行 %s",
SlashCompactFailed: "压缩失败",
+2
View File
@@ -165,6 +165,8 @@ var ChineseTraditional = Messages{
CompactionChangesKept: "處變更已保留",
CompactionRepaired: "已補救",
ReadinessContinuing: "這一輪還欠東西,繼續把它做完",
ExtFormFieldsHint: "欄位將透過逐項提問收集",
ExtRunActionFmt: "執行 %s",
SlashCompactFailed: "壓縮失敗",