418 lines
15 KiB
Go
418 lines
15 KiB
Go
package control
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"reasonix/internal/agent"
|
|
"reasonix/internal/autoresearch"
|
|
"reasonix/internal/event"
|
|
"reasonix/internal/evidence"
|
|
"reasonix/internal/jobs"
|
|
"reasonix/internal/provider"
|
|
"reasonix/internal/skill"
|
|
"reasonix/internal/tool"
|
|
)
|
|
|
|
// turnOrchestrator owns foreground turn execution while Controller keeps the
|
|
// public ports, run-state guard, and session-scoped dependencies.
|
|
type turnOrchestrator struct {
|
|
c *Controller
|
|
}
|
|
|
|
type orchestratedTurn struct {
|
|
input string
|
|
raw string
|
|
display string
|
|
editedOriginal string
|
|
synthetic bool
|
|
}
|
|
|
|
func newTurnOrchestrator(c *Controller) *turnOrchestrator {
|
|
return &turnOrchestrator{c: c}
|
|
}
|
|
|
|
func (o *turnOrchestrator) runTurnWithRawDisplay(ctx context.Context, input, raw, display string) error {
|
|
return o.runOrchestratedTurn(ctx, orchestratedTurn{input: input, raw: raw, display: display})
|
|
}
|
|
|
|
func (o *turnOrchestrator) runEditedTurnWithRawDisplay(ctx context.Context, input, raw, display, original string) error {
|
|
return o.runOrchestratedTurn(ctx, orchestratedTurn{input: input, raw: raw, display: display, editedOriginal: original})
|
|
}
|
|
|
|
func (o *turnOrchestrator) runSyntheticTurnWithRawDisplay(ctx context.Context, input, raw, display string) error {
|
|
return o.runOrchestratedTurn(ctx, orchestratedTurn{input: input, raw: raw, display: display, synthetic: true})
|
|
}
|
|
|
|
func (o *turnOrchestrator) runComposedSyntheticTurn(ctx context.Context, text string) error {
|
|
c := o.c
|
|
return c.runner.Run(agent.WithMemoryCompilerSkip(ctx), c.ComposeSynthetic(text))
|
|
}
|
|
|
|
// runSubagentSkillGoalLoop executes a slash-invoked runAs=subagent skill as a
|
|
// real isolated child turn, then lets an active goal continue just as an inline
|
|
// skill turn did before.
|
|
func (o *turnOrchestrator) runSubagentSkillGoalLoop(ctx context.Context, sk skill.Skill, task, raw, display string, runner skill.SubagentRunner, planMode bool) error {
|
|
return o.runSubagentSkillTurnsGoalLoop(ctx, []skill.Skill{sk}, task, raw, display, runner, planMode)
|
|
}
|
|
|
|
func (o *turnOrchestrator) runSubagentSkillTurnsGoalLoop(ctx context.Context, skills []skill.Skill, task, raw, display string, runner skill.SubagentRunner, planMode bool) error {
|
|
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)
|
|
}
|
|
|
|
// runSubagentSkillTurns records the composed user task and distilled child
|
|
// answers only. Child reasoning and tool chatter stay out of the
|
|
// provider-visible parent context while their UI events nest under synthetic
|
|
// top-level run_skill cards.
|
|
func (o *turnOrchestrator) runSubagentSkillTurns(ctx context.Context, skills []skill.Skill, task, raw, display string, runner skill.SubagentRunner, planMode bool) error {
|
|
c := o.c
|
|
c.maybeSessionStart(ctx)
|
|
parentSession := c.parentSessionID()
|
|
images := c.inputImages(raw)
|
|
ctx = agent.WithParentSession(ctx, parentSession)
|
|
ctx = jobs.WithSession(ctx, parentSession)
|
|
ctx = agent.WithUserImages(ctx, images)
|
|
ctx = agent.WithResponseLanguagePreference(ctx, c.responseLanguage)
|
|
ctx = agent.WithReasoningLanguagePreference(ctx, c.reasoningLanguage)
|
|
|
|
input := c.compose(task, raw, true)
|
|
startMessages := c.messageCount()
|
|
defer c.snapshotActivityIfChanged(startMessages)
|
|
defer c.recordDisplayForNewUser(startMessages, display)
|
|
c.beginCheckpoint(input)
|
|
if c.guardianSess != nil {
|
|
c.guardianSess.ResetTurn()
|
|
}
|
|
if c.hooks.Enabled() {
|
|
c.mu.Lock()
|
|
c.turn++
|
|
turn := c.turn
|
|
c.mu.Unlock()
|
|
if block, _ := c.hooks.PromptSubmit(ctx, input, turn); block {
|
|
return nil
|
|
}
|
|
defer func() { c.hooks.Stop(context.Background(), lastAssistantText(c.History()), turn) }()
|
|
}
|
|
|
|
c.markInFlightTurn(startMessages, true)
|
|
inFlight := true
|
|
defer func() {
|
|
if inFlight {
|
|
c.clearInFlightTurn()
|
|
}
|
|
}()
|
|
c.sink.Emit(event.Event{Kind: event.TurnStarted})
|
|
if c.executor == nil {
|
|
return fmt.Errorf("subagent slash invocation requires an active session")
|
|
}
|
|
c.executor.Session().Add(provider.Message{Role: provider.RoleUser, Content: input, Images: images})
|
|
|
|
for _, sk := range skills {
|
|
callID := fmt.Sprintf("slash-skill-%d", c.slashSkillSeq.Add(1))
|
|
args, _ := json.Marshal(map[string]string{"name": sk.Name, "arguments": task})
|
|
toolEvent := event.Tool{
|
|
ID: callID,
|
|
Name: "run_skill",
|
|
Args: string(args),
|
|
ReadOnly: planMode || sk.ReadOnly,
|
|
}
|
|
if c.skillProfile != nil {
|
|
toolEvent.Profile = c.skillProfile(sk)
|
|
}
|
|
c.sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: toolEvent})
|
|
runCtx := agent.WithToolCallContext(ctx, callID, c.sink, c, planMode)
|
|
runCtx = agent.WithSubagentDepth(runCtx, 0)
|
|
answer, err := runner(runCtx, sk, input, skill.SubagentRunOptions{HostInitiated: true})
|
|
if err != nil {
|
|
toolEvent.Err = err.Error()
|
|
c.sink.Emit(event.Event{Kind: event.ToolResult, Tool: toolEvent})
|
|
return err
|
|
}
|
|
answer = tool.GuardSubagentHostDecisionText(answer)
|
|
toolEvent.Output = answer
|
|
c.sink.Emit(event.Event{Kind: event.ToolResult, Tool: toolEvent})
|
|
c.executor.Session().Add(provider.Message{Role: provider.RoleAssistant, Content: answer})
|
|
c.sink.Emit(event.Event{Kind: event.Text, Text: answer})
|
|
c.sink.Emit(event.Event{Kind: event.Message, Text: answer})
|
|
}
|
|
|
|
c.clearInFlightTurn()
|
|
inFlight = false
|
|
return nil
|
|
}
|
|
|
|
func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchestratedTurn) error {
|
|
c := o.c
|
|
c.maybeSessionStart(ctx)
|
|
if !turn.synthetic {
|
|
c.maybeAutoPlan(ctx, turn.raw)
|
|
}
|
|
parentSession := c.parentSessionID()
|
|
ctx = agent.WithParentSession(ctx, parentSession)
|
|
ctx = jobs.WithSession(ctx, parentSession)
|
|
ctx = agent.WithUserImages(ctx, c.inputImages(turn.input))
|
|
// Synthetic, controller-injected turns (goal-loop continuation,
|
|
// plan-approved execution, …) must not be Memory v5-compiled: compiling them
|
|
// re-injects a contract the model echoes back, which spins the goal loop
|
|
// forever (#5342, #5329). Only genuine user turns supply a compiler source.
|
|
if turn.synthetic || IsSyntheticUserMessage(turn.raw) {
|
|
ctx = agent.WithMemoryCompilerSkip(ctx)
|
|
} else {
|
|
ctx = agent.WithMemoryCompilerSourceInput(ctx, turn.raw)
|
|
}
|
|
input := c.compose(turn.input, turn.raw, !turn.synthetic)
|
|
startMessages := c.messageCount()
|
|
defer c.snapshotActivityIfChanged(startMessages)
|
|
defer c.recordDisplayForNewUser(startMessages, turn.display)
|
|
if turn.editedOriginal != "" {
|
|
defer c.markEditedForNewUser(startMessages, turn.editedOriginal)
|
|
}
|
|
// Open a checkpoint only for visible user turns before the user message is
|
|
// appended, so the recorded message boundary precedes it and pre-edit
|
|
// snapshots land here. Synthetic continuations stay attached to the visible
|
|
// turn that spawned them; otherwise hidden user-role messages would advance
|
|
// backend checkpoint turns without a matching frontend turn.
|
|
if !turn.synthetic {
|
|
c.beginCheckpoint(input)
|
|
}
|
|
if c.guardianSess != nil {
|
|
c.guardianSess.ResetTurn()
|
|
}
|
|
// UserPromptSubmit / Stop hooks bracket the whole turn (incl. the plan
|
|
// research + approved-execution sub-turns below): a gating UserPromptSubmit
|
|
// aborts before any model call; Stop fires once when the turn returns.
|
|
if c.hooks.Enabled() {
|
|
c.mu.Lock()
|
|
c.turn++
|
|
turn := c.turn
|
|
c.mu.Unlock()
|
|
if block, _ := c.hooks.PromptSubmit(ctx, input, turn); block {
|
|
return nil // the hook's notify callback already surfaced the reason
|
|
}
|
|
defer func() { c.hooks.Stop(context.Background(), lastAssistantText(c.History()), turn) }()
|
|
}
|
|
c.markInFlightTurn(startMessages, !turn.synthetic && !IsSyntheticUserMessage(turn.raw))
|
|
autoResearchTaskID := c.goals.currentAutoResearchTaskID()
|
|
autoResearchAcceptedBefore := c.autoResearchAcceptedEvidenceIDs(autoResearchTaskID)
|
|
c.appendAutoResearchHeartbeat(autoResearchTaskID, autoresearch.HeartbeatStartingTurn, "")
|
|
modelInput := input
|
|
if !turn.synthetic {
|
|
modelInput = c.withCapabilityRoute(input, turn.raw)
|
|
}
|
|
err := c.runner.Run(ctx, modelInput)
|
|
if err == nil {
|
|
c.recordAutoResearchEvidenceFromAssistant(autoResearchTaskID, lastAssistantText(c.History()))
|
|
c.recordAutoResearchTurnProgress(autoResearchTaskID, autoResearchAcceptedBefore)
|
|
c.appendAutoResearchHeartbeat(autoResearchTaskID, autoresearch.HeartbeatTurnDone, "")
|
|
c.clearInFlightTurn()
|
|
} else {
|
|
c.appendAutoResearchHeartbeat(autoResearchTaskID, autoresearch.HeartbeatWarning, err.Error())
|
|
// When the user explicitly cancels (Ctrl+C), the incomplete turn's
|
|
// assistant messages and tool results are already saved to the
|
|
// session. If they stay, the next turn's model sees leftover
|
|
// in-progress todo items and partial tool calls and may re-execute
|
|
// the interrupted work. Keep the real user prompt for visible turns so
|
|
// follow-up questions and resumes do not lose the user's context (#5499).
|
|
if errors.Is(err, context.Canceled) && c.CancelRequested() {
|
|
if turn.synthetic || IsSyntheticUserMessage(turn.raw) {
|
|
c.stripTurnMessagesAfter(startMessages)
|
|
} else {
|
|
c.stripCancelledVisibleTurnMessagesAfter(startMessages)
|
|
}
|
|
}
|
|
c.clearInFlightTurn()
|
|
return err
|
|
}
|
|
c.mu.Lock()
|
|
plan := c.planMode
|
|
c.mu.Unlock()
|
|
if !plan {
|
|
return nil
|
|
}
|
|
proposal := lastAssistantText(c.History())
|
|
if proposal == "" {
|
|
return nil // no substantive proposal to gate
|
|
}
|
|
// The plan is already visible as the assistant's answer, so the request
|
|
// carries no subject — it's purely the gate.
|
|
allow, _, err := c.requestApproval(ctx, planApprovalTool, "", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !allow {
|
|
return nil // keep planning; plan mode stays on
|
|
}
|
|
c.SetPlanMode(false)
|
|
todoArgs := c.seedPlanTodos(proposal)
|
|
execStart := c.sessionMessageCount()
|
|
// The plan is the go-ahead: don't re-prompt for each write of the approved
|
|
// work. Auto-approve writers for the duration of this execution turn only; a
|
|
// later turn (even "continue") falls back to the normal per-tool approval.
|
|
c.approval.setPlanAutoApprove(true)
|
|
defer c.approval.setPlanAutoApprove(false)
|
|
err = func() error {
|
|
c.markInFlightTurn(execStart, false)
|
|
defer c.clearInFlightTurn()
|
|
return o.runComposedSyntheticTurn(ctx, planApprovedMessage)
|
|
}()
|
|
if err != nil {
|
|
if errors.Is(err, context.Canceled) && c.CancelRequested() {
|
|
c.stripTurnMessagesAfter(execStart)
|
|
}
|
|
return err
|
|
}
|
|
if todoArgs != "" && !c.hasTodoUpdateSince(execStart) {
|
|
c.completePlanTodos(todoArgs)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (o *turnOrchestrator) runGoalLoopWithRawDisplay(ctx context.Context, input, raw, display string) error {
|
|
if err := o.runTurnWithRawDisplay(ctx, input, raw, display); err != nil {
|
|
if ctx.Err() != nil {
|
|
o.c.stopGoal(GoalStatusStopped)
|
|
}
|
|
return err
|
|
}
|
|
return o.continueGoal(ctx)
|
|
}
|
|
|
|
func (o *turnOrchestrator) runEditedGoalLoopWithRawDisplay(ctx context.Context, input, raw, display, original string) error {
|
|
if err := o.runEditedTurnWithRawDisplay(ctx, input, raw, display, original); err != nil {
|
|
if ctx.Err() != nil {
|
|
o.c.stopGoal(GoalStatusStopped)
|
|
}
|
|
return err
|
|
}
|
|
return o.continueGoal(ctx)
|
|
}
|
|
|
|
func (o *turnOrchestrator) continueGoal(ctx context.Context) error {
|
|
c := o.c
|
|
for {
|
|
cont := o.advanceGoalAfterTurn()
|
|
if !cont {
|
|
return nil
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
c.stopGoal(GoalStatusStopped)
|
|
return err
|
|
}
|
|
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)
|
|
} else {
|
|
c.noticeDetail("Goal still has unfinished task state; continuing the remaining work.", msg)
|
|
}
|
|
}
|
|
if err := o.runSyntheticTurnWithRawDisplay(ctx, turn, turn, ""); err != nil {
|
|
if ctx.Err() != nil {
|
|
c.stopGoal(GoalStatusStopped)
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
func (o *turnOrchestrator) advanceGoalAfterTurn() bool {
|
|
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
|
|
// of these touch goal state, so the machine's critical section stays pure.
|
|
status, reason, _ := parseGoalStatusMarker(lastAssistantText(c.History()))
|
|
autoResearchTaskID := c.goals.currentAutoResearchTaskID()
|
|
var readiness string
|
|
if c.executor != nil {
|
|
readiness = c.executor.GoalReadinessFailure()
|
|
}
|
|
if arReadiness := c.autoResearchReadinessFailure(); arReadiness != "" {
|
|
if readiness != "" {
|
|
readiness += "\n" + arReadiness
|
|
} else {
|
|
readiness = arReadiness
|
|
}
|
|
}
|
|
res := c.goals.advance(goalAdvanceInput{
|
|
status: status,
|
|
reason: reason,
|
|
toolCalled: c.toolWasCalledLastTurn(),
|
|
todos: c.goalTodos(),
|
|
readiness: readiness,
|
|
})
|
|
c.persistGoalState(res.path, res.data, res.ok)
|
|
if res.notice != "" {
|
|
c.finalizeAutoResearchTask(autoResearchTaskID, res.notice)
|
|
c.notice(res.notice)
|
|
}
|
|
if res.notice == goalCompleteNotice && c.executor != nil {
|
|
c.completeRemainingGoalTodos()
|
|
}
|
|
return res.cont
|
|
}
|
|
|
|
func (c *Controller) finalizeAutoResearchTask(taskID, notice string) {
|
|
if c.autoResearch == nil || strings.TrimSpace(taskID) == "" {
|
|
return
|
|
}
|
|
switch {
|
|
case notice == goalCompleteNotice:
|
|
status := autoresearch.StatusComplete
|
|
if _, err := c.autoResearch.UpdateProgress(taskID, autoresearch.ProgressPatch{Status: &status}); err != nil {
|
|
c.noticeDetail("AutoResearch status update failed.", "autoresearch task completion update failed: "+err.Error())
|
|
return
|
|
}
|
|
c.notice("autoresearch task completed: " + taskID)
|
|
case strings.HasPrefix(notice, "goal blocked: ") || notice == "goal continuation limit reached":
|
|
status := autoresearch.StatusBlocked
|
|
reason := strings.TrimPrefix(notice, "goal blocked: ")
|
|
if reason == "" {
|
|
reason = notice
|
|
}
|
|
if _, err := c.autoResearch.UpdateProgress(taskID, autoresearch.ProgressPatch{Status: &status, BlockedReason: &reason}); err != nil {
|
|
c.noticeDetail("AutoResearch status update failed.", "autoresearch task blocked update failed: "+err.Error())
|
|
return
|
|
}
|
|
c.noticeDetail("AutoResearch task marked blocked.", "autoresearch task blocked: "+taskID+"\nreason: "+reason)
|
|
}
|
|
}
|
|
|
|
// completeRemainingGoalTodos force-completes any remaining incomplete canonical
|
|
// todos when the goal FSM transitions to completed and emits a synthetic
|
|
// todo_write event so the frontend panel reflects the final state. Handles the
|
|
// second [goal:complete] override (non-strict) where the model does not mark
|
|
// each todo individually.
|
|
func (c *Controller) completeRemainingGoalTodos() {
|
|
todos := c.executor.CanonicalTodoState()
|
|
if len(evidence.IncompleteTodos(todos)) == 0 {
|
|
return
|
|
}
|
|
for i := range todos {
|
|
todos[i].Status = "completed"
|
|
}
|
|
args, err := json.Marshal(map[string]any{"todos": todos})
|
|
if err != nil {
|
|
return
|
|
}
|
|
t := event.Tool{ID: "goal-final", Name: "todo_write", Args: string(args), ReadOnly: true}
|
|
c.sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: t})
|
|
t.Output = "goal completed"
|
|
c.sink.Emit(event.Event{Kind: event.ToolResult, Tool: t})
|
|
c.executor.ReplaceTodoState(todos)
|
|
// Persist the completed todo state so a session reload does not revert
|
|
// to the old incomplete list — the synthetic todo_write events are not
|
|
// part of the session transcript and rebuildTodoState would otherwise
|
|
// reconstruct the stale pre-completion state.
|
|
c.goals.persistWithTodos(todos)
|
|
}
|