mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
346a258559
commit
e4e6936b1d
10 files changed
+217
-28
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -171,6 +171,8 @@ var Chinese = Messages{
|
||||
CompactionChangesKept: "处改动已保留",
|
||||
CompactionRepaired: "已补救",
|
||||
|
||||
ReadinessContinuing: "这一轮还欠东西,继续把它做完",
|
||||
|
||||
ExtFormFieldsHint: "字段将通过逐项提问收集",
|
||||
ExtRunActionFmt: "运行 %s",
|
||||
SlashCompactFailed: "压缩失败",
|
||||
|
||||
@@ -165,6 +165,8 @@ var ChineseTraditional = Messages{
|
||||
CompactionChangesKept: "處變更已保留",
|
||||
CompactionRepaired: "已補救",
|
||||
|
||||
ReadinessContinuing: "這一輪還欠東西,繼續把它做完",
|
||||
|
||||
ExtFormFieldsHint: "欄位將透過逐項提問收集",
|
||||
ExtRunActionFmt: "執行 %s",
|
||||
SlashCompactFailed: "壓縮失敗",
|
||||
|
||||
Reference in new issue
Block a user