mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge pull request #6961 from erphenheimer/refactor/6958-intercept-output-model
refactor(goal): bind FSM intercept outputs to Goal lifecycle / 让 FSM 拦截输出绑定 Goal 生命周期
This commit is contained in:
5 files changed
+386
-61
No files matched your search
+91
-31
@@ -46,11 +46,11 @@ type goalMachine struct {
|
||||
turns int
|
||||
blocks int
|
||||
block string
|
||||
interceptMsg string
|
||||
intercepts int
|
||||
strict bool
|
||||
selfCheckDone bool
|
||||
idleTurns int
|
||||
continuationEpoch uint64
|
||||
|
||||
// statePath is the persisted goal-state sidecar; empty disables persistence.
|
||||
statePath string
|
||||
@@ -86,7 +86,6 @@ type goalMachineSnapshot struct {
|
||||
turns int
|
||||
blocks int
|
||||
block string
|
||||
interceptMsg string
|
||||
intercepts int
|
||||
strict bool
|
||||
selfCheckDone bool
|
||||
@@ -96,22 +95,35 @@ type goalMachineSnapshot struct {
|
||||
// goalAdvanceInput carries everything the FSM needs for one continuation step,
|
||||
// gathered by the caller off the machine's lock.
|
||||
type goalAdvanceInput struct {
|
||||
status string // parsed marker status ("" when the turn carried no marker)
|
||||
reason string // blocked reason from the marker, if any
|
||||
toolCalled bool // whether the last turn made any tool call
|
||||
todos []evidence.TodoItem
|
||||
readiness string // executor.GoalReadinessFailure()
|
||||
status string // parsed marker status ("" when the turn carried no marker)
|
||||
reason string // blocked reason from the marker, if any
|
||||
toolCalled bool // whether the last turn made any tool call
|
||||
todos []evidence.TodoItem
|
||||
readiness string // executor.GoalReadinessFailure()
|
||||
expectedEpoch *uint64 // owning turn's lifecycle; nil for direct FSM calls
|
||||
}
|
||||
|
||||
// goalAdvanceResult reports the FSM step's outcome. data/path/ok describe the
|
||||
// state to persist (built under mu when something changed); notice is surfaced
|
||||
// to the user; cont reports whether the goal loop should continue.
|
||||
type goalAdvanceResult struct {
|
||||
notice string
|
||||
cont bool
|
||||
path string
|
||||
data []byte
|
||||
ok bool
|
||||
notice string
|
||||
intercept string
|
||||
cont bool
|
||||
continuationEpoch uint64
|
||||
path string
|
||||
data []byte
|
||||
ok bool
|
||||
}
|
||||
|
||||
// goalContinuationSnapshot binds a continuation to the exact Goal lifecycle
|
||||
// state admitted for its synthetic turn. The orchestrator uses these captured
|
||||
// fields throughout the turn instead of re-reading a possibly replaced Goal.
|
||||
type goalContinuationSnapshot struct {
|
||||
goal string
|
||||
researchMode GoalResearchMode
|
||||
autoResearchTaskID string
|
||||
scopeID string
|
||||
}
|
||||
|
||||
// goalStatePath derives a session's persisted goal-state sidecar.
|
||||
@@ -132,7 +144,7 @@ func (g *goalMachine) capture() goalMachineSnapshot {
|
||||
goal: g.goal, status: g.status, researchMode: g.researchMode,
|
||||
autoResearchTaskID: g.autoResearchTaskID, scopeID: g.scopeID,
|
||||
deliveryCheckpoint: g.deliveryCheckpoint, turns: g.turns,
|
||||
blocks: g.blocks, block: g.block, interceptMsg: g.interceptMsg,
|
||||
blocks: g.blocks, block: g.block,
|
||||
intercepts: g.intercepts, strict: g.strict,
|
||||
selfCheckDone: g.selfCheckDone, idleTurns: g.idleTurns,
|
||||
}
|
||||
@@ -144,8 +156,9 @@ func (g *goalMachine) restore(snapshot goalMachineSnapshot) {
|
||||
g.autoResearchTaskID, g.scopeID = snapshot.autoResearchTaskID, snapshot.scopeID
|
||||
g.deliveryCheckpoint, g.turns = snapshot.deliveryCheckpoint, snapshot.turns
|
||||
g.blocks, g.block = snapshot.blocks, snapshot.block
|
||||
g.interceptMsg, g.intercepts = snapshot.interceptMsg, snapshot.intercepts
|
||||
g.intercepts = snapshot.intercepts
|
||||
g.strict, g.selfCheckDone, g.idleTurns = snapshot.strict, snapshot.selfCheckDone, snapshot.idleTurns
|
||||
g.continuationEpoch++
|
||||
g.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -171,6 +184,15 @@ func (g *goalMachine) currentAutoResearchTaskID() string {
|
||||
return g.autoResearchTaskID
|
||||
}
|
||||
|
||||
// continuationToken captures the Goal lifecycle that owns an outgoing turn.
|
||||
// The matching assistant output may advance the FSM only while this epoch is
|
||||
// still current.
|
||||
func (g *goalMachine) continuationToken() uint64 {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.continuationEpoch
|
||||
}
|
||||
|
||||
func (g *goalMachine) deliveryScope() (id, task string, ok bool) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
@@ -218,8 +240,9 @@ func (g *goalMachine) set(goal string, mode GoalResearchMode, autoResearchTaskID
|
||||
if goal != "" && g.goal == goal && g.status == GoalStatusRunning && g.researchMode == mode && g.autoResearchTaskID == autoResearchTaskID {
|
||||
return "", nil, false
|
||||
}
|
||||
g.continuationEpoch++
|
||||
g.turns, g.blocks, g.block = 0, 0, ""
|
||||
g.interceptMsg, g.intercepts = "", 0
|
||||
g.intercepts = 0
|
||||
g.selfCheckDone, g.idleTurns, g.strict = false, 0, false
|
||||
if goal == "" {
|
||||
g.goal, g.status, g.researchMode, g.autoResearchTaskID = "", GoalStatusStopped, GoalResearchAuto, ""
|
||||
@@ -245,10 +268,10 @@ func (g *goalMachine) setStrict(strict bool, todos []evidence.TodoItem) (string,
|
||||
func (g *goalMachine) stop(status string, todos []evidence.TodoItem) (string, []byte, bool) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.continuationEpoch++
|
||||
if strings.TrimSpace(g.goal) != "" && g.status == GoalStatusRunning {
|
||||
g.status = status
|
||||
}
|
||||
g.interceptMsg = ""
|
||||
g.intercepts = 0
|
||||
g.selfCheckDone = false
|
||||
g.idleTurns = 0
|
||||
@@ -261,9 +284,10 @@ func (g *goalMachine) resume(todos []evidence.TodoItem) (path string, data []byt
|
||||
if strings.TrimSpace(g.goal) == "" || g.status == GoalStatusComplete {
|
||||
return "", nil, false, false
|
||||
}
|
||||
g.continuationEpoch++
|
||||
g.status = GoalStatusRunning
|
||||
g.blocks, g.block = 0, ""
|
||||
g.interceptMsg, g.intercepts = "", 0
|
||||
g.intercepts = 0
|
||||
g.selfCheckDone, g.idleTurns = false, 0
|
||||
if g.scopeID == "" {
|
||||
g.scopeID = newGoalScopeID()
|
||||
@@ -288,16 +312,43 @@ func (g *goalMachine) deliveryState() evidence.DeliveryCheckpoint {
|
||||
return g.deliveryCheckpoint
|
||||
}
|
||||
|
||||
// takeIntercept consumes a pending continuation-turn override, if any.
|
||||
func (g *goalMachine) takeIntercept() (string, bool) {
|
||||
// acceptContinuation checks an advance result before the orchestrator surfaces
|
||||
// its notice. admitContinuation revalidates after synchronous notice callbacks
|
||||
// and captures the Goal state at the synthetic-turn admission boundary.
|
||||
func (g *goalMachine) acceptContinuation(res goalAdvanceResult) (string, bool) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.interceptMsg == "" {
|
||||
if !res.cont ||
|
||||
res.continuationEpoch != g.continuationEpoch ||
|
||||
strings.TrimSpace(g.goal) == "" ||
|
||||
g.status != GoalStatusRunning {
|
||||
return "", false
|
||||
}
|
||||
msg := g.interceptMsg
|
||||
g.interceptMsg = ""
|
||||
return msg, true
|
||||
return res.intercept, true
|
||||
}
|
||||
|
||||
// admitContinuation atomically validates an advance result and captures the
|
||||
// Goal state used to compose and scope its synthetic turn. Keeping validation
|
||||
// and capture in one critical section prevents a stale intercept from being
|
||||
// paired with a replacement Goal between those operations.
|
||||
func (g *goalMachine) admitContinuation(res goalAdvanceResult) (goalContinuationSnapshot, bool) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if !res.cont ||
|
||||
res.continuationEpoch != g.continuationEpoch ||
|
||||
strings.TrimSpace(g.goal) == "" ||
|
||||
g.status != GoalStatusRunning {
|
||||
return goalContinuationSnapshot{}, false
|
||||
}
|
||||
if g.scopeID == "" {
|
||||
g.scopeID = newGoalScopeID()
|
||||
}
|
||||
return goalContinuationSnapshot{
|
||||
goal: g.goal,
|
||||
researchMode: g.researchMode,
|
||||
autoResearchTaskID: g.autoResearchTaskID,
|
||||
scopeID: g.scopeID,
|
||||
}, true
|
||||
}
|
||||
|
||||
// advance runs one continuation step of the goal FSM from already-gathered
|
||||
@@ -306,25 +357,30 @@ func (g *goalMachine) takeIntercept() (string, bool) {
|
||||
func (g *goalMachine) advance(in goalAdvanceInput) goalAdvanceResult {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if in.expectedEpoch != nil && *in.expectedEpoch != g.continuationEpoch {
|
||||
return goalAdvanceResult{cont: false}
|
||||
}
|
||||
if strings.TrimSpace(g.goal) == "" || g.status != GoalStatusRunning {
|
||||
return goalAdvanceResult{cont: false}
|
||||
}
|
||||
g.continuationEpoch++
|
||||
g.turns++
|
||||
var notice string
|
||||
var intercept string
|
||||
switch in.status {
|
||||
case GoalStatusComplete:
|
||||
if incomplete := formatIncompleteTodos(in.todos, in.readiness); len(incomplete) > 0 && (g.strict || g.intercepts == 0) {
|
||||
// In strict mode every claim is blocked until todos are done;
|
||||
// otherwise only the first consecutive claim is intercepted.
|
||||
g.intercepts++
|
||||
g.interceptMsg = incomplete
|
||||
intercept = incomplete
|
||||
break
|
||||
}
|
||||
// Todos are all done — in strict mode run self-check before final
|
||||
// completion. Non-strict mode completes immediately.
|
||||
if g.strict && !g.selfCheckDone {
|
||||
g.selfCheckDone = true
|
||||
g.interceptMsg = goalSelfCheckTurn
|
||||
intercept = goalSelfCheckTurn
|
||||
break
|
||||
}
|
||||
// Self-check passed — complete the goal.
|
||||
@@ -335,7 +391,6 @@ func (g *goalMachine) advance(in goalAdvanceInput) goalAdvanceResult {
|
||||
g.status = GoalStatusComplete
|
||||
g.blocks = 0
|
||||
g.block = ""
|
||||
g.interceptMsg = ""
|
||||
notice = goalCompleteNotice
|
||||
case GoalStatusBlocked:
|
||||
g.idleTurns = 0
|
||||
@@ -363,14 +418,14 @@ func (g *goalMachine) advance(in goalAdvanceInput) goalAdvanceResult {
|
||||
// Idle detection: if the agent went multiple turns without any tool calls,
|
||||
// inject a reminder to make progress (unless the goal is already completing
|
||||
// or hitting the auto-turn limit).
|
||||
if notice == "" && g.interceptMsg == "" {
|
||||
if notice == "" && intercept == "" {
|
||||
if in.toolCalled {
|
||||
g.idleTurns = 0
|
||||
} else {
|
||||
g.idleTurns++
|
||||
if g.idleTurns >= maxGoalIdleTurns {
|
||||
g.idleTurns = 0
|
||||
g.interceptMsg = "No tool calls in recent turns. Either make progress with tools or signal [goal:blocked:<reason>]."
|
||||
intercept = "No tool calls in recent turns. Either make progress with tools or signal [goal:blocked:<reason>]."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,11 +434,15 @@ func (g *goalMachine) advance(in goalAdvanceInput) goalAdvanceResult {
|
||||
g.block = "goal continuation limit reached"
|
||||
g.intercepts = 0
|
||||
g.selfCheckDone = false
|
||||
g.interceptMsg = ""
|
||||
g.idleTurns = 0
|
||||
notice = g.block
|
||||
}
|
||||
res := goalAdvanceResult{notice: notice, cont: notice == ""}
|
||||
res := goalAdvanceResult{
|
||||
notice: notice,
|
||||
intercept: intercept,
|
||||
cont: notice == "",
|
||||
continuationEpoch: g.continuationEpoch,
|
||||
}
|
||||
if notice != "" {
|
||||
res.path, res.data, res.ok = g.buildStateLocked(in.todos)
|
||||
}
|
||||
@@ -531,8 +590,9 @@ func (g *goalMachine) restoreFromState(sessionPath string) {
|
||||
g.blocks = state.Blocks
|
||||
g.block = state.Block
|
||||
g.strict = state.Strict
|
||||
g.interceptMsg, g.intercepts = "", 0
|
||||
g.intercepts = 0
|
||||
g.selfCheckDone, g.idleTurns = false, 0
|
||||
g.continuationEpoch++
|
||||
}
|
||||
|
||||
// formatIncompleteTodos renders the reminder shown when [goal:complete] arrives
|
||||
|
||||
@@ -771,8 +771,8 @@ func TestGoalBlockedSignalDoesNotTriggerIdleIntercept(t *testing.T) {
|
||||
if !res.cont {
|
||||
t.Fatal("first blocked signal should keep the goal audit running")
|
||||
}
|
||||
if msg, ok := g.takeIntercept(); ok {
|
||||
t.Fatalf("blocked signal triggered idle intercept %q", msg)
|
||||
if res.intercept != "" {
|
||||
t.Fatalf("blocked signal triggered idle intercept %q", res.intercept)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -913,6 +913,63 @@ func TestGoalInterceptsCompleteWithIncompleteTodos(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoalAdvanceResultCannotCrossGoalLifecycle(t *testing.T) {
|
||||
newResult := func(t *testing.T, g *goalMachine) goalAdvanceResult {
|
||||
t.Helper()
|
||||
g.set("old goal", GoalResearchAuto, "", nil)
|
||||
res := g.advance(goalAdvanceInput{
|
||||
status: GoalStatusComplete,
|
||||
toolCalled: true,
|
||||
todos: []evidence.TodoItem{{
|
||||
Content: "unfinished work from old goal",
|
||||
Status: "in_progress",
|
||||
}},
|
||||
})
|
||||
if res.intercept == "" {
|
||||
t.Fatal("test setup: expected an incomplete-todo intercept")
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
t.Run("current result is accepted", func(t *testing.T) {
|
||||
var g goalMachine
|
||||
res := newResult(t, &g)
|
||||
if got, ok := g.acceptContinuation(res); !ok || got != res.intercept {
|
||||
t.Fatalf("acceptContinuation() = (%q, %v), want current intercept", got, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("replacement goal invalidates result", func(t *testing.T) {
|
||||
var g goalMachine
|
||||
res := newResult(t, &g)
|
||||
g.set("replacement goal", GoalResearchAuto, "", nil)
|
||||
if got, ok := g.acceptContinuation(res); ok {
|
||||
t.Fatalf("replacement goal accepted stale intercept %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stop and resume invalidates result", func(t *testing.T) {
|
||||
var g goalMachine
|
||||
res := newResult(t, &g)
|
||||
g.stop(GoalStatusStopped, nil)
|
||||
if _, _, _, resumed := g.resume(nil); !resumed {
|
||||
t.Fatal("test setup: goal did not resume")
|
||||
}
|
||||
if got, ok := g.acceptContinuation(res); ok {
|
||||
t.Fatalf("resumed goal accepted stale intercept %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("newer advance invalidates result", func(t *testing.T) {
|
||||
var g goalMachine
|
||||
res := newResult(t, &g)
|
||||
g.advance(goalAdvanceInput{toolCalled: true})
|
||||
if got, ok := g.acceptContinuation(res); ok {
|
||||
t.Fatalf("newer FSM step accepted stale intercept %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestGoalOverrideCompletesRemainingTodos verifies that when the goal
|
||||
// completes via the second [goal:complete] override (non-strict mode), any
|
||||
// remaining incomplete canonical todos are force-completed and a synthetic
|
||||
|
||||
@@ -138,13 +138,31 @@ func (c *Controller) Compose(text string) string {
|
||||
}
|
||||
|
||||
func (c *Controller) compose(text, source string, includeHookContext bool) string {
|
||||
goal, goalStatus, goalResearchMode, autoResearchTaskID := c.goals.snapshot()
|
||||
return c.composeWithGoal(
|
||||
text,
|
||||
source,
|
||||
includeHookContext,
|
||||
goal,
|
||||
goalStatus,
|
||||
goalResearchMode,
|
||||
autoResearchTaskID,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Controller) composeWithGoal(
|
||||
text, source string,
|
||||
includeHookContext bool,
|
||||
goal, goalStatus string,
|
||||
goalResearchMode GoalResearchMode,
|
||||
autoResearchTaskID string,
|
||||
) string {
|
||||
c.mu.Lock()
|
||||
plan := c.planMode
|
||||
responseLanguage := c.responseLanguage
|
||||
reasoningLanguage := c.reasoningLanguage
|
||||
c.mu.Unlock()
|
||||
notes := c.memory.drainPending()
|
||||
goal, goalStatus, goalResearchMode, autoResearchTaskID := c.goals.snapshot()
|
||||
|
||||
if strings.TrimSpace(goal) != "" && goalStatus == GoalStatusRunning {
|
||||
prefix := activeGoalBlock(goal, goalResearchMode)
|
||||
|
||||
@@ -25,11 +25,12 @@ type turnOrchestrator struct {
|
||||
}
|
||||
|
||||
type orchestratedTurn struct {
|
||||
input string
|
||||
raw string
|
||||
display string
|
||||
editedOriginal string
|
||||
synthetic bool
|
||||
input string
|
||||
raw string
|
||||
display string
|
||||
editedOriginal string
|
||||
synthetic bool
|
||||
goalContinuation *goalContinuationSnapshot
|
||||
}
|
||||
|
||||
func newTurnOrchestrator(c *Controller) *turnOrchestrator {
|
||||
@@ -48,6 +49,25 @@ func (o *turnOrchestrator) runSyntheticTurnWithRawDisplay(ctx context.Context, i
|
||||
return o.runOrchestratedTurn(ctx, orchestratedTurn{input: input, raw: raw, display: display, synthetic: true})
|
||||
}
|
||||
|
||||
func (o *turnOrchestrator) runGoalContinuationTurnWithRawDisplay(
|
||||
ctx context.Context,
|
||||
input, raw, display string,
|
||||
res goalAdvanceResult,
|
||||
) (bool, error) {
|
||||
snapshot, ok := o.c.goals.admitContinuation(res)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
err := o.runOrchestratedTurn(ctx, orchestratedTurn{
|
||||
input: input,
|
||||
raw: raw,
|
||||
display: display,
|
||||
synthetic: true,
|
||||
goalContinuation: &snapshot,
|
||||
})
|
||||
return true, err
|
||||
}
|
||||
|
||||
func (o *turnOrchestrator) runComposedSyntheticTurn(ctx context.Context, text string) error {
|
||||
c := o.c
|
||||
ctx = agent.WithRawUserInput(ctx, text)
|
||||
@@ -63,13 +83,14 @@ func (o *turnOrchestrator) runSubagentSkillGoalLoop(ctx context.Context, sk skil
|
||||
}
|
||||
|
||||
func (o *turnOrchestrator) runSubagentSkillTurnsGoalLoop(ctx context.Context, skills []skill.Skill, task, raw, display string, runner skill.SubagentRunner, planMode bool) error {
|
||||
expectedContinuationEpoch := o.c.goals.continuationToken()
|
||||
if err := o.runSubagentSkillTurns(ctx, skills, task, raw, display, runner, planMode); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
o.c.stopGoal(GoalStatusStopped)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return o.continueGoal(ctx)
|
||||
return o.continueGoal(ctx, expectedContinuationEpoch)
|
||||
}
|
||||
|
||||
// runSubagentSkillTurns records the composed user task and distilled child
|
||||
@@ -163,7 +184,21 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
|
||||
userImages := c.inputImages(turn.input)
|
||||
ctx = agent.WithUserImages(ctx, userImages)
|
||||
ctx = agent.WithRawUserInput(ctx, turn.raw)
|
||||
input := c.compose(turn.input, turn.raw, !turn.synthetic)
|
||||
continuation := turn.goalContinuation
|
||||
var input string
|
||||
if continuation != nil {
|
||||
input = c.composeWithGoal(
|
||||
turn.input,
|
||||
turn.raw,
|
||||
false,
|
||||
continuation.goal,
|
||||
GoalStatusRunning,
|
||||
continuation.researchMode,
|
||||
continuation.autoResearchTaskID,
|
||||
)
|
||||
} else {
|
||||
input = c.compose(turn.input, turn.raw, !turn.synthetic)
|
||||
}
|
||||
startMessages := c.messageCount()
|
||||
defer c.snapshotActivityIfChanged(startMessages)
|
||||
defer c.recordDisplayForNewUser(startMessages, turn.display)
|
||||
@@ -195,14 +230,24 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
|
||||
defer func() { c.hooks.StopResult(context.Background(), lastAssistantText(c.History()), turn, err) }()
|
||||
}
|
||||
c.markInFlightTurn(startMessages, !turn.synthetic && !IsSyntheticUserMessage(turn.raw))
|
||||
autoResearchTaskID := c.goals.currentAutoResearchTaskID()
|
||||
var autoResearchTaskID string
|
||||
if continuation != nil {
|
||||
autoResearchTaskID = continuation.autoResearchTaskID
|
||||
} else {
|
||||
autoResearchTaskID = c.goals.currentAutoResearchTaskID()
|
||||
}
|
||||
autoResearchAcceptedBefore := c.autoResearchAcceptedEvidenceIDs(autoResearchTaskID)
|
||||
c.appendAutoResearchHeartbeat(autoResearchTaskID, autoresearch.HeartbeatStartingTurn, "")
|
||||
modelInput := input
|
||||
if !turn.synthetic {
|
||||
modelInput = c.withCapabilityRoute(input, turn.raw)
|
||||
}
|
||||
if scopeID, task, ok := c.goals.deliveryScope(); ok {
|
||||
if continuation != nil {
|
||||
ctx = agent.WithDeliveryExecutionScope(ctx, agent.DeliveryExecutionScope{
|
||||
ID: continuation.scopeID,
|
||||
TaskText: continuation.goal,
|
||||
})
|
||||
} else if scopeID, task, ok := c.goals.deliveryScope(); ok {
|
||||
ctx = agent.WithDeliveryExecutionScope(ctx, agent.DeliveryExecutionScope{ID: scopeID, TaskText: task})
|
||||
}
|
||||
ctx = c.withPlannerTurnMetadata(ctx, turn.raw, turn.synthetic, startMessages)
|
||||
@@ -302,6 +347,7 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
|
||||
}
|
||||
|
||||
func (o *turnOrchestrator) runGoalLoopWithRawDisplay(ctx context.Context, input, raw, display string) error {
|
||||
expectedContinuationEpoch := o.c.goals.continuationToken()
|
||||
if err := o.runTurnWithRawDisplay(ctx, input, raw, display); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
o.c.stopGoal(GoalStatusStopped)
|
||||
@@ -310,10 +356,11 @@ func (o *turnOrchestrator) runGoalLoopWithRawDisplay(ctx context.Context, input,
|
||||
}
|
||||
return err
|
||||
}
|
||||
return o.continueGoal(ctx)
|
||||
return o.continueGoal(ctx, expectedContinuationEpoch)
|
||||
}
|
||||
|
||||
func (o *turnOrchestrator) runEditedGoalLoopWithRawDisplay(ctx context.Context, input, raw, display, original string) error {
|
||||
expectedContinuationEpoch := o.c.goals.continuationToken()
|
||||
if err := o.runEditedTurnWithRawDisplay(ctx, input, raw, display, original); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
o.c.stopGoal(GoalStatusStopped)
|
||||
@@ -322,7 +369,7 @@ func (o *turnOrchestrator) runEditedGoalLoopWithRawDisplay(ctx context.Context,
|
||||
}
|
||||
return err
|
||||
}
|
||||
return o.continueGoal(ctx)
|
||||
return o.continueGoal(ctx, expectedContinuationEpoch)
|
||||
}
|
||||
|
||||
// goalShouldBlockOnError reports host pauses that permanently block a Goal
|
||||
@@ -335,36 +382,45 @@ func goalShouldBlockOnError(err error) bool {
|
||||
return errors.As(err, &readiness)
|
||||
}
|
||||
|
||||
func (o *turnOrchestrator) continueGoal(ctx context.Context) error {
|
||||
func (o *turnOrchestrator) continueGoal(ctx context.Context, expectedContinuationEpoch uint64) error {
|
||||
c := o.c
|
||||
for {
|
||||
cont := o.advanceGoalAfterTurn()
|
||||
if !cont {
|
||||
res := o.advanceGoalAfterTurn(expectedContinuationEpoch)
|
||||
if !res.cont {
|
||||
return nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
c.stopGoal(GoalStatusStopped)
|
||||
return err
|
||||
}
|
||||
intercept, ok := c.goals.acceptContinuation(res)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
turn := goalContinueTurn
|
||||
if msg, ok := c.goals.takeIntercept(); ok {
|
||||
turn = msg
|
||||
if strings.Contains(msg, "AutoResearch readiness check failed") {
|
||||
c.noticeDetail("Goal is not ready to complete yet; continuing the remaining work.", msg)
|
||||
if intercept != "" {
|
||||
turn = intercept
|
||||
if strings.Contains(intercept, "AutoResearch readiness check failed") {
|
||||
c.noticeDetail("Goal is not ready to complete yet; continuing the remaining work.", intercept)
|
||||
} else {
|
||||
c.noticeDetail("Goal still has unfinished task state; continuing the remaining work.", msg)
|
||||
c.noticeDetail("Goal still has unfinished task state; continuing the remaining work.", intercept)
|
||||
}
|
||||
}
|
||||
if err := o.runSyntheticTurnWithRawDisplay(ctx, turn, turn, ""); err != nil {
|
||||
admitted, err := o.runGoalContinuationTurnWithRawDisplay(ctx, turn, turn, "", res)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
c.stopGoal(GoalStatusStopped)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !admitted {
|
||||
return nil
|
||||
}
|
||||
expectedContinuationEpoch = res.continuationEpoch
|
||||
}
|
||||
}
|
||||
|
||||
func (o *turnOrchestrator) advanceGoalAfterTurn() bool {
|
||||
func (o *turnOrchestrator) advanceGoalAfterTurn(expectedContinuationEpoch uint64) goalAdvanceResult {
|
||||
c := o.c
|
||||
// Gather every input the FSM needs off the goal lock: parse the marker,
|
||||
// snapshot the executor's todos + readiness, and check tool activity. None
|
||||
@@ -383,11 +439,12 @@ func (o *turnOrchestrator) advanceGoalAfterTurn() bool {
|
||||
}
|
||||
}
|
||||
res := c.goals.advance(goalAdvanceInput{
|
||||
status: status,
|
||||
reason: reason,
|
||||
toolCalled: c.toolWasCalledLastTurn(),
|
||||
todos: c.goalTodos(),
|
||||
readiness: readiness,
|
||||
status: status,
|
||||
reason: reason,
|
||||
toolCalled: c.toolWasCalledLastTurn(),
|
||||
todos: c.goalTodos(),
|
||||
readiness: readiness,
|
||||
expectedEpoch: &expectedContinuationEpoch,
|
||||
})
|
||||
c.persistGoalState(res.path, res.data, res.ok)
|
||||
if res.notice != "" {
|
||||
@@ -397,7 +454,7 @@ func (o *turnOrchestrator) advanceGoalAfterTurn() bool {
|
||||
if res.notice == goalCompleteNotice && c.executor != nil {
|
||||
c.completeRemainingGoalTodos()
|
||||
}
|
||||
return res.cont
|
||||
return res
|
||||
}
|
||||
|
||||
func (c *Controller) finalizeAutoResearchTask(taskID, notice string) {
|
||||
|
||||
@@ -24,6 +24,25 @@ type plannerMetadataRunner struct {
|
||||
input string
|
||||
}
|
||||
|
||||
type goalReplacingRunner struct {
|
||||
c *Controller
|
||||
executor *agent.Agent
|
||||
calls int
|
||||
}
|
||||
|
||||
func (r *goalReplacingRunner) Run(context.Context, string) error {
|
||||
r.calls++
|
||||
if r.calls == 1 {
|
||||
r.c.SetGoal("replacement goal")
|
||||
r.executor.ReplaceTodoState(nil)
|
||||
r.executor.Session().Add(provider.Message{
|
||||
Role: provider.RoleAssistant,
|
||||
Content: "Old Goal turn finished.\n\n[goal:complete]",
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *plannerMetadataRunner) Run(ctx context.Context, input string) error {
|
||||
r.meta, _ = plannerTurnMetadataFromContext(ctx)
|
||||
r.input = input
|
||||
@@ -102,6 +121,120 @@ func TestTurnOrchestratorTypedSyntheticTurnDoesNotDependOnPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoalTurnOutputCannotAdvanceReplacementGoal(t *testing.T) {
|
||||
executor := agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
|
||||
runner := &goalReplacingRunner{executor: executor}
|
||||
c := New(Options{
|
||||
Runner: runner,
|
||||
Executor: executor,
|
||||
SessionDir: t.TempDir(),
|
||||
})
|
||||
runner.c = c
|
||||
c.SetGoal("old goal")
|
||||
|
||||
if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(
|
||||
context.Background(),
|
||||
"work on the old goal",
|
||||
"work on the old goal",
|
||||
"",
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runner.calls != 1 {
|
||||
t.Fatalf("runner calls = %d, want 1 old-Goal turn", runner.calls)
|
||||
}
|
||||
if got := c.Goal(); got != "replacement goal" {
|
||||
t.Fatalf("Goal() = %q, want replacement Goal to remain active", got)
|
||||
}
|
||||
if got := c.GoalStatus(); got != GoalStatusRunning {
|
||||
t.Fatalf("GoalStatus() = %q, want replacement Goal to remain running", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoalContinuationNoticeCannotMoveOldInterceptIntoReplacementGoal(t *testing.T) {
|
||||
runner := &fakeTurnRunner{}
|
||||
session := agent.NewSession("")
|
||||
session.Add(provider.Message{
|
||||
Role: provider.RoleAssistant,
|
||||
Content: "All done.\n\n[goal:complete]",
|
||||
})
|
||||
executor := agent.New(nil, tool.NewRegistry(), session, agent.Options{}, event.Discard)
|
||||
executor.SeedTodoState([]evidence.TodoItem{{
|
||||
Content: "unfinished work from old goal",
|
||||
Status: "in_progress",
|
||||
}})
|
||||
|
||||
var c *Controller
|
||||
replaced := false
|
||||
c = New(Options{
|
||||
Runner: runner,
|
||||
Executor: executor,
|
||||
Sink: event.FuncSink(func(e event.Event) {
|
||||
if replaced ||
|
||||
e.Kind != event.Notice ||
|
||||
!strings.Contains(e.Text, "Goal still has unfinished task state") {
|
||||
return
|
||||
}
|
||||
replaced = true
|
||||
c.SetGoal("replacement goal")
|
||||
}),
|
||||
})
|
||||
c.SetGoal("old goal")
|
||||
|
||||
if err := newTurnOrchestrator(c).continueGoal(
|
||||
context.Background(),
|
||||
c.goals.continuationToken(),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !replaced {
|
||||
t.Fatal("test setup: Notice callback did not replace the active Goal")
|
||||
}
|
||||
if got := c.Goal(); got != "replacement goal" {
|
||||
t.Fatalf("Goal() = %q, want replacement goal", got)
|
||||
}
|
||||
if len(runner.inputs) != 0 {
|
||||
t.Fatalf("stale continuation reached runner with input %q", runner.inputs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoalContinuationOutputCannotAdvanceReplacementGoal(t *testing.T) {
|
||||
session := agent.NewSession("system")
|
||||
session.Add(provider.Message{
|
||||
Role: provider.RoleAssistant,
|
||||
Content: "All done.\n\n[goal:complete]",
|
||||
})
|
||||
executor := agent.New(nil, tool.NewRegistry(), session, agent.Options{}, event.Discard)
|
||||
executor.SeedTodoState([]evidence.TodoItem{{
|
||||
Content: "unfinished work from old goal",
|
||||
Status: "in_progress",
|
||||
}})
|
||||
runner := &goalReplacingRunner{executor: executor}
|
||||
c := New(Options{
|
||||
Runner: runner,
|
||||
Executor: executor,
|
||||
SessionDir: t.TempDir(),
|
||||
})
|
||||
runner.c = c
|
||||
c.SetGoal("old goal")
|
||||
|
||||
if err := newTurnOrchestrator(c).continueGoal(
|
||||
context.Background(),
|
||||
c.goals.continuationToken(),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runner.calls != 1 {
|
||||
t.Fatalf("runner calls = %d, want 1 old-Goal continuation", runner.calls)
|
||||
}
|
||||
if got := c.Goal(); got != "replacement goal" {
|
||||
t.Fatalf("Goal() = %q, want replacement Goal to remain active", got)
|
||||
}
|
||||
if got := c.GoalStatus(); got != GoalStatusRunning {
|
||||
t.Fatalf("GoalStatus() = %q, want replacement Goal to remain running", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnOrchestratorStopHookIgnoresCanceledTurnContext(t *testing.T) {
|
||||
runCtx, cancel := context.WithCancel(context.Background())
|
||||
var stopCalls int
|
||||
|
||||
Reference in new issue
Block a user