mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(control): resolve the projection seer on the current executor
The seer controller.New bound was the executor's method value, and that value reads the session in place: a SetSession from /new, resume, a branch switch, an extension system prompt or a fold is already visible to it. Those sites answer from the current session. A model switch is different — it builds a new controller, and ApplyRuntimeMigration adopts the old conversation into it. The carried turns still hold the standing blocks the old model was sent, so the derived rule reported them delivered and the new model's first request carried no listing. The seer now resolves through the controller and answers "nothing is visible" for a generation that has delivered nothing yet. ApplyRuntimeMigration marks that state when the replacement's model ref differs from the outgoing one; the hand-off to the runner clears it, so exactly one request restates the standing blocks and the visible history answers after that. A rebuild that keeps the model keeps the derived answer.
This commit is contained in:
1 parent
87965d1863
commit
2a8bdbce0b
5 files changed
+168
-5
No files matched your search
@@ -0,0 +1,115 @@
|
||||
package boot
|
||||
|
||||
// A model switch must restate the standing blocks to the new model once: the
|
||||
// conversation it adopts was composed for the model being replaced.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
func TestProjectionModelSwitchReOwesTheStandingBlocksOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
home := statedBootHome(t)
|
||||
dir := testenv.TempDir(t)
|
||||
rec := &effectRecordingProvider{}
|
||||
provider.Register("boot-projection-switch", func(provider.Config) (provider.Provider, error) { return rec, nil })
|
||||
writeFile(t, dir, ".reasonix/skills/ledger-audit/SKILL.md", `---
|
||||
name: ledger-audit
|
||||
description: Audits the evidence ledger for receipts nothing reads.
|
||||
---
|
||||
|
||||
Read the ledger and report.
|
||||
`)
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "model-a"
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
[[providers]]
|
||||
name = "model-a"
|
||||
kind = "boot-projection-switch"
|
||||
model = "a"
|
||||
|
||||
[[providers]]
|
||||
name = "model-b"
|
||||
kind = "boot-projection-switch"
|
||||
model = "b"
|
||||
`)
|
||||
approveWorkspaceAt(t, home, dir)
|
||||
|
||||
from, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: dir, Sink: event.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("Build model-a: %v", err)
|
||||
}
|
||||
defer from.Close()
|
||||
from.EnsureSessionPath()
|
||||
if err := from.Run(context.Background(), "turn-first"); err != nil {
|
||||
t.Fatalf("Run(turn-first): %v", err)
|
||||
}
|
||||
first := rec.requests()[0]
|
||||
if !strings.Contains(projectionOf(t, first, "turn-first"), "<available-skills>") {
|
||||
t.Fatalf("the first model's request carried no listing, so this test measures nothing:\n%s", projectionOf(t, first, "turn-first"))
|
||||
}
|
||||
if err := from.Snapshot(); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
|
||||
to, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: dir, Model: "model-b", Sink: event.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("Build model-b: %v", err)
|
||||
}
|
||||
defer to.Close()
|
||||
if err := ApplyRuntimeMigration(to, from, CaptureRuntimeMigration(from)); err != nil {
|
||||
t.Fatalf("ApplyRuntimeMigration: %v", err)
|
||||
}
|
||||
// The adopted conversation already holds the listing the old model was sent;
|
||||
// that copy is not a delivery to this model.
|
||||
before := len(rec.requests())
|
||||
if err := to.Run(context.Background(), "turn-switched"); err != nil {
|
||||
t.Fatalf("Run(turn-switched): %v", err)
|
||||
}
|
||||
switched := rec.requests()[before:]
|
||||
if len(switched) != 1 {
|
||||
t.Fatalf("switch request count = %d, want 1", len(switched))
|
||||
}
|
||||
if got := projectionOf(t, switched[0], "turn-switched"); !strings.Contains(got, "<available-skills>") {
|
||||
t.Fatalf("the switched model's first request carries no listing — it inherits another model's delivery:\n%s", got)
|
||||
}
|
||||
|
||||
// One restatement, not a repeat on every turn: this runtime has now put the
|
||||
// listing in front of its model, and the visible history says so.
|
||||
before = len(rec.requests())
|
||||
if err := to.Run(context.Background(), "turn-second"); err != nil {
|
||||
t.Fatalf("Run(turn-second): %v", err)
|
||||
}
|
||||
second := rec.requests()[before:]
|
||||
if got := projectionOf(t, second[len(second)-1], "turn-second"); strings.Contains(got, "<available-skills>") {
|
||||
t.Fatalf("the restatement repeated on the next turn:\n%s", got)
|
||||
}
|
||||
|
||||
// A rebuild that keeps the model carries the conversation verbatim: the copy
|
||||
// the history already holds is delivered, and no arm may re-send it.
|
||||
same, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: dir, Model: "model-b", Sink: event.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("Build model-b again: %v", err)
|
||||
}
|
||||
defer same.Close()
|
||||
if err := ApplyRuntimeMigration(same, to, CaptureRuntimeMigration(to)); err != nil {
|
||||
t.Fatalf("ApplyRuntimeMigration (same model): %v", err)
|
||||
}
|
||||
before = len(rec.requests())
|
||||
if err := same.Run(context.Background(), "turn-rebuilt"); err != nil {
|
||||
t.Fatalf("Run(turn-rebuilt): %v", err)
|
||||
}
|
||||
rebuilt := rec.requests()[before:]
|
||||
if got := projectionOf(t, rebuilt[len(rebuilt)-1], "turn-rebuilt"); strings.Contains(got, "<available-skills>") {
|
||||
t.Fatalf("a rebuild on the same model re-sent a listing the conversation already carries:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,7 @@ func rebuildWithPrevious(ctx context.Context, old *control.Controller, previous
|
||||
// conversation, and the session axes a switch must not silently reset.
|
||||
type RuntimeMigration struct {
|
||||
prevPath string
|
||||
prevModelRef string
|
||||
carried []provider.Message
|
||||
authorizations control.SessionAuthorizations
|
||||
toolApprovalMode string
|
||||
@@ -162,6 +163,7 @@ type RuntimeMigration struct {
|
||||
func CaptureRuntimeMigration(old control.SessionAPI) RuntimeMigration {
|
||||
m := RuntimeMigration{
|
||||
prevPath: old.SessionPath(),
|
||||
prevModelRef: old.ModelRef(),
|
||||
carried: old.History(),
|
||||
toolApprovalMode: old.ToolApprovalMode(),
|
||||
postureDefaulted: old.Posture().Defaulted,
|
||||
@@ -185,6 +187,11 @@ func ApplyRuntimeMigration(ctrl, old *control.Controller, m RuntimeMigration) er
|
||||
carried := spliceFreshSystemPrompt(m.carried, ctrl.History())
|
||||
path := sessionstore.ContinueSessionPath(m.prevPath, ctrl.SessionDir(), ctrl.Label())
|
||||
ctrl.AdoptHistory(carried, path)
|
||||
// Same conversation, different model: the carried blocks were composed
|
||||
// for the model being replaced, so this one is owed them once.
|
||||
if old != nil && m.prevModelRef != ctrl.ModelRef() {
|
||||
ctrl.RestateStandingProjection()
|
||||
}
|
||||
|
||||
// Re-apply session axes a rebuild must not reset.
|
||||
if m.postureDefaulted {
|
||||
|
||||
@@ -92,6 +92,9 @@ type Controller struct {
|
||||
guardianPath string // persisted guardian session file ("" when disabled)
|
||||
systemPrompt string
|
||||
commands atomic.Pointer[[]command.Command]
|
||||
// restateProjection answers "not visible" until a turn is handed to the
|
||||
// runner; a model switch sets it. See projection_debt.go.
|
||||
restateProjection atomic.Bool
|
||||
// hookContexts carries one-shot lifecycle hook context into the next real
|
||||
// user turn without changing the cache-stable system prompt.
|
||||
hookContexts []string
|
||||
@@ -482,7 +485,7 @@ func New(opts Options) *Controller {
|
||||
runtimeGeneration: opts.RuntimeGeneration,
|
||||
}
|
||||
if c.executor != nil {
|
||||
c.bindProjectionSeer(c.executor.NewestStandingBlock, c.executor.SeesProjection)
|
||||
c.bindProjectionSeer(c.deliveredNewest, c.deliveredSees)
|
||||
}
|
||||
c.adoptSessionResources(opts)
|
||||
c.publishPerProjectContext(opts)
|
||||
|
||||
@@ -42,6 +42,42 @@ func (c *Controller) bindProjectionSeer(newest func(string) (string, bool), note
|
||||
c.deliveryNotes(notes)
|
||||
}
|
||||
|
||||
// deliveredNewest is the seer a controller uses for its own model: it resolves
|
||||
// the executor held at call time, so an adopted or rotated session is answered
|
||||
// from the session this runtime has now, and it answers "nothing is visible"
|
||||
// until a turn is handed to the runner. A switched model inherits another
|
||||
// model's delivery, so the replacement restates the blocks once (see Restate).
|
||||
func (c *Controller) deliveredNewest(tag string) (string, bool) {
|
||||
if c == nil || c.restateProjection.Load() || c.executor == nil {
|
||||
return "", false
|
||||
}
|
||||
return c.executor.NewestStandingBlock(tag)
|
||||
}
|
||||
|
||||
// deliveredSees is deliveredNewest's question for the one-time notes.
|
||||
func (c *Controller) deliveredSees(text string) bool {
|
||||
if c == nil || c.restateProjection.Load() || c.executor == nil {
|
||||
return false
|
||||
}
|
||||
return c.executor.SeesProjection(text)
|
||||
}
|
||||
|
||||
// RestateStandingProjection marks this generation as having delivered nothing to
|
||||
// the model it is about to address: a rebuild that switched models adopted a
|
||||
// conversation composed for the model being replaced, not for this one.
|
||||
func (c *Controller) RestateStandingProjection() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.restateProjection.Store(true)
|
||||
}
|
||||
|
||||
// settleProjection records that a composed turn is being handed to the runner.
|
||||
// From here on the visible history is the record of what this runtime delivered.
|
||||
func (c *Controller) settleProjection() {
|
||||
c.restateProjection.Store(false)
|
||||
}
|
||||
|
||||
// deliveryNotes binds the one-time facts a turn also carries. They are not
|
||||
// standing blocks: a note is delivered when the model can read it anywhere.
|
||||
func (c *Controller) deliveryNotes(notes func(string) bool) {
|
||||
|
||||
@@ -162,13 +162,15 @@ func (c *Controller) recallTail(source string, hasNotes bool) string {
|
||||
// a hardware exception on Windows, recovered or not.
|
||||
var ErrNoRunner = errors.New("controller has no runner")
|
||||
|
||||
// runWithRunner hands the composed turn to the runner. Nothing about delivery is
|
||||
// recorded here: whether the model holds a projected block is asked of the
|
||||
// model-visible history, so a refusal, a cancel or a failed send needs no
|
||||
// compensating bookkeeping.
|
||||
// runWithRunner hands the composed turn to the runner. Whether the model holds a
|
||||
// projected block is asked of the model-visible history, so a refusal, a cancel
|
||||
// or a failed send needs no compensating bookkeeping. The exception is the
|
||||
// opening restatement of a rebuilt runtime (see projection_debt.go): this
|
||||
// hand-off ends that one-turn state.
|
||||
func (c *Controller) runWithRunner(ctx context.Context, input string) error {
|
||||
if c.runner == nil {
|
||||
return ErrNoRunner
|
||||
}
|
||||
c.settleProjection()
|
||||
return c.runner.Run(ctx, input)
|
||||
}
|
||||
Reference in new issue
Block a user