| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "time" |
| 9 | |
| 10 | "reasonix/internal/agent" |
| 11 | "reasonix/internal/event" |
| 12 | "reasonix/internal/jobs" |
| 13 | "reasonix/internal/provider" |
| 14 | "reasonix/internal/skill" |
| 15 | "reasonix/internal/tool" |
| 16 | ) |
| 17 | |
| 18 | // turnOrchestrator owns foreground turn execution while Controller keeps the |
| 19 | // public ports, run-state guard, and session-scoped dependencies. |
| 20 | type turnOrchestrator struct { |
| 21 | c *Controller |
| 22 | } |
| 23 | |
| 24 | type orchestratedTurn struct { |
| 25 | input string |
| 26 | raw string |
| 27 | imageRefs string |
| 28 | userImages []string |
| 29 | imageCandidates []string |
| 30 | imagesResolved bool |
| 31 | display string |
| 32 | editedOriginal string |
| 33 | synthetic bool |
| 34 | goalRound *goalRoundReservation |
| 35 | } |
| 36 | |
| 37 | func newTurnOrchestrator(c *Controller) *turnOrchestrator { |
| 38 | return &turnOrchestrator{c: c} |
| 39 | } |
| 40 | |
| 41 | func (o *turnOrchestrator) runTurnWithRawDisplay(ctx context.Context, input, raw, display string) error { |
| 42 | return o.runOrchestratedTurn(ctx, orchestratedTurn{input: input, raw: raw, display: display}) |
| 43 | } |
| 44 | |
| 45 | func (o *turnOrchestrator) runTurnWithImageRefsRawDisplay(ctx context.Context, input, raw, imageRefs, display string) error { |
| 46 | return o.runOrchestratedTurn(ctx, orchestratedTurn{input: input, raw: raw, imageRefs: imageRefs, display: display}) |
| 47 | } |
| 48 | |
| 49 | func (o *turnOrchestrator) runSyntheticTurnWithRawDisplay(ctx context.Context, input, raw, display string) error { |
| 50 | return o.runOrchestratedTurn(ctx, orchestratedTurn{input: input, raw: raw, display: display, synthetic: true}) |
| 51 | } |
| 52 | |
| 53 | func (o *turnOrchestrator) runComposedSyntheticTurn(ctx context.Context, text string) error { |
| 54 | c := o.c |
| 55 | ctx = agent.WithRawUserInput(ctx, text) |
| 56 | ctx = withTurnInputOrigin(ctx, true) |
| 57 | ctx = c.withTurnContext(ctx, false) |
| 58 | ctx = c.withPlannerTurnMetadata(ctx, text, true, c.messageCount()) |
| 59 | return c.runModelTurn(ctx, c.ComposeSynthetic(text)) |
| 60 | } |
| 61 | |
| 62 | // runSubagentSkillGoalLoop executes a slash-invoked runAs=subagent skill as a |
| 63 | // real isolated child turn, then lets an active goal continue just as an inline |
| 64 | // skill turn did before. |
| 65 | func (o *turnOrchestrator) runSubagentSkillGoalLoop(ctx context.Context, sk skill.Skill, task, raw, display string, runner skill.SubagentRunner, planMode bool) error { |
| 66 | return o.runSubagentSkillTurnsGoalLoop(ctx, []skill.Skill{sk}, task, raw, display, runner, planMode) |
| 67 | } |
| 68 | |
| 69 | func (o *turnOrchestrator) runSubagentSkillTurnsGoalLoop(ctx context.Context, skills []skill.Skill, task, raw, display string, runner skill.SubagentRunner, planMode bool, frozen ...[]string) error { |
| 70 | var userImages, imageCandidates []string |
| 71 | if len(frozen) > 0 { |
| 72 | imageCandidates = append([]string(nil), frozen[0]...) |
| 73 | if o.c.imageInputEnabled() { |
| 74 | userImages = append([]string(nil), imageCandidates...) |
| 75 | } |
| 76 | } else { |
| 77 | userImages, imageCandidates = o.c.resolveTurnImages(raw) |
| 78 | } |
| 79 | ctx = agent.WithSubagentImageCandidates(ctx, imageCandidates) |
| 80 | ctx = o.c.withPreparedTurnImages(ctx) |
| 81 | return o.runSubagentSkillTurns(ctx, skills, task, raw, display, runner, planMode, userImages, imageCandidates) |
| 82 | } |
| 83 | |
| 84 | // runSubagentSkillTurns records the composed user task and distilled child |
| 85 | // answers only. Child reasoning and tool chatter stay out of the |
| 86 | // provider-visible parent context while their UI events nest under synthetic |
| 87 | // top-level run_skill cards. |
| 88 | func (o *turnOrchestrator) runSubagentSkillTurns(ctx context.Context, skills []skill.Skill, task, raw, display string, runner skill.SubagentRunner, planMode bool, images, imageCandidates []string) (err error) { |
| 89 | c := o.c |
| 90 | turnStartedAt := time.Now() |
| 91 | c.maybeSessionStart(ctx) |
| 92 | parentSession := c.parentSessionID() |
| 93 | ctx = agent.WithParentSession(ctx, parentSession) |
| 94 | ctx = jobs.WithSession(ctx, parentSession) |
| 95 | ctx = agent.WithUserImages(ctx, images) |
| 96 | ctx = agent.WithSubagentImageCandidates(ctx, imageCandidates) |
| 97 | ctx = agent.WithResponseLanguagePreference(ctx, c.responseLanguage) |
| 98 | ctx = agent.WithReasoningLanguagePreference(ctx, c.reasoningLanguage) |
| 99 | ctx = c.withTurnContext(ctx, true) |
| 100 | |
| 101 | input := c.compose(task, raw, true) |
| 102 | startMessages := c.messageCount() |
| 103 | var marker agent.InFlightTurnMeta |
| 104 | defer func() { c.finishInFlightTurn(startMessages, marker) }() |
| 105 | defer c.recordDisplayForNewUser(startMessages, display) |
| 106 | // The checkpoint prompt labels the turn in the rewind picker (and is |
| 107 | // prefilled into the composer after a conversation rewind), so it must be |
| 108 | // the user's own text — never the composed provider input with its |
| 109 | // transient <response-language>/<reasoning-language>/memory/hook blocks. |
| 110 | c.beginCheckpoint(ctx, firstNonEmpty(raw, task)) |
| 111 | if c.guardianSess != nil { |
| 112 | c.guardianSess.ResetTurn() |
| 113 | } |
| 114 | if c.hooks.Enabled() { |
| 115 | c.mu.Lock() |
| 116 | c.turn++ |
| 117 | turn := c.turn |
| 118 | c.mu.Unlock() |
| 119 | if block, _ := c.hooks.PromptSubmit(ctx, input, turn); block { |
| 120 | return nil |
| 121 | } |
| 122 | defer func() { c.hooks.StopResult(context.Background(), lastAssistantText(c.History()), turn, err) }() |
| 123 | } |
| 124 | |
| 125 | marker = c.markInFlightTurn(startMessages, true) |
| 126 | c.sink.Emit(event.Event{Kind: event.TurnStarted}) |
| 127 | if c.executor == nil { |
| 128 | return fmt.Errorf("subagent slash invocation requires an active session") |
| 129 | } |
| 130 | message := persistedUserTurn(input, firstNonEmpty(raw, task), images, time.Now().UnixMilli()) |
| 131 | if prepared, ok := ctx.Value(preparedImageReferencesContextKey{}).(preparedImageReferences); ok && len(prepared.inputs) > 0 { |
| 132 | message.Images = nil |
| 133 | message.ImageInputs = prepared.inputs |
| 134 | } |
| 135 | if _, err := c.executor.AppendTurnContextAndUserChecked(ctx, message); err != nil { |
| 136 | return err |
| 137 | } |
| 138 | |
| 139 | for _, sk := range skills { |
| 140 | sk = c.skills.prepare(sk) |
| 141 | callID := fmt.Sprintf("slash-skill-%d", c.slashSkillSeq.Add(1)) |
| 142 | args, _ := json.Marshal(map[string]string{"name": sk.Name, "arguments": task}) |
| 143 | toolEvent := event.Tool{ |
| 144 | ID: callID, |
| 145 | Name: "run_skill", |
| 146 | Args: string(args), |
| 147 | ReadOnly: sk.ReadOnly, |
| 148 | } |
| 149 | if c.skillProfile != nil { |
| 150 | toolEvent.Profile = c.skillProfile(sk) |
| 151 | } |
| 152 | if err := event.EmitChecked(c.sink, event.Event{Kind: event.ToolDispatch, Tool: toolEvent}); err != nil { |
| 153 | return fmt.Errorf("persist skill dispatch: %w", err) |
| 154 | } |
| 155 | runCtx := agent.WithToolCallContext(ctx, callID, c.sink, c, planMode) |
| 156 | runCtx = agent.WithSubagentDepth(runCtx, 0) |
| 157 | answer, err := runner(runCtx, sk, input, skill.SubagentRunOptions{HostInitiated: true}) |
| 158 | if err != nil { |
| 159 | toolEvent.Err = err.Error() |
| 160 | c.sink.Emit(event.Event{Kind: event.ToolResult, Tool: toolEvent}) |
| 161 | return err |
| 162 | } |
| 163 | answer = tool.GuardSubagentHostDecisionText(answer) |
| 164 | toolEvent.Output = answer |
| 165 | c.sink.Emit(event.Event{Kind: event.ToolResult, Tool: toolEvent}) |
| 166 | workDurationMs := max(int64(1), time.Since(turnStartedAt).Milliseconds()) |
| 167 | messageID := agent.NewMessageID() |
| 168 | assistant := provider.Message{ID: messageID, Role: provider.RoleAssistant, Content: answer, WorkDurationMs: workDurationMs} |
| 169 | if err := c.RecordSessionMessages(ctx, "orchestrated-assistant", []provider.Message{assistant}); err != nil { |
| 170 | return err |
| 171 | } |
| 172 | c.executor.Session().Add(assistant) |
| 173 | display := agent.DisplayAssistantText(answer) |
| 174 | c.sink.Emit(event.Event{Kind: event.Text, MessageID: messageID, Text: display}) |
| 175 | c.sink.Emit(event.Event{Kind: event.Message, MessageID: messageID, Text: display}) |
| 176 | } |
| 177 | |
| 178 | return nil |
| 179 | } |
| 180 | |
| 181 | func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchestratedTurn) (err error) { |
| 182 | c := o.c |
| 183 | c.maybeSessionStart(ctx) |
| 184 | parentSession := c.parentSessionID() |
| 185 | ctx = agent.WithParentSession(ctx, parentSession) |
| 186 | ctx = jobs.WithSession(ctx, parentSession) |
| 187 | userImages, imageCandidates := c.imagesForOrchestratedTurn(ctx, turn) |
| 188 | ctx = agent.WithUserImages(ctx, userImages) |
| 189 | ctx = agent.WithSubagentImageCandidates(ctx, imageCandidates) |
| 190 | ctx = agent.WithRawUserInput(ctx, turn.raw) |
| 191 | ctx = c.withPreparedTurnImages(ctx) |
| 192 | ctx = withTurnInputOrigin(ctx, turn.synthetic) |
| 193 | userMessageID := agent.NewMessageID() |
| 194 | if _, turnID, active := c.currentTurnToken(); active { |
| 195 | if receipt, ok := c.submissionForTurn(turnID); ok { |
| 196 | userMessageID = receipt.MessageID |
| 197 | } |
| 198 | } |
| 199 | if c.executor != nil { |
| 200 | ctx = agent.WithUserMessageIdentity(ctx, c.executor.Session(), userMessageID) |
| 201 | } |
| 202 | var input string |
| 203 | if turn.goalRound != nil { |
| 204 | input = c.ComposeSynthetic(turn.input) |
| 205 | } else { |
| 206 | input = c.compose(turn.input, turn.raw, !turn.synthetic) |
| 207 | } |
| 208 | // input.receive: the composed text crosses the extension chain before it |
| 209 | // enters the session (checkpoint, hooks, and the model all see the final |
| 210 | // text). A block ruling aborts the turn with the redacted reason surfaced, |
| 211 | // mirroring the PromptSubmit hook's abort path; a required-class extension |
| 212 | // failure fails the turn. |
| 213 | input, blocked, interceptErr := c.interceptInputReceive(ctx, input) |
| 214 | if interceptErr != nil { |
| 215 | return interceptErr |
| 216 | } |
| 217 | if blocked { |
| 218 | return nil |
| 219 | } |
| 220 | startMessages := c.messageCount() |
| 221 | fallback := persistedUserTurn(input, turn.raw, userImages, time.Now().UnixMilli()) |
| 222 | fallback.ID = userMessageID |
| 223 | c.noteTerminationBoundary(fallback, !turn.synthetic) |
| 224 | var marker agent.InFlightTurnMeta |
| 225 | defer func() { c.finishInFlightTurn(startMessages, marker) }() |
| 226 | defer c.recordDisplayForNewUser(startMessages, turn.display) |
| 227 | if turn.editedOriginal != "" { |
| 228 | defer c.markEditedForNewUser(startMessages, turn.editedOriginal) |
| 229 | } |
| 230 | // Open a checkpoint only for visible user turns before the user message is |
| 231 | // appended, so the recorded message boundary precedes it and pre-edit |
| 232 | // snapshots land here. Synthetic continuations stay attached to the visible |
| 233 | // turn that spawned them; otherwise hidden user-role messages would advance |
| 234 | // backend checkpoint turns without a matching frontend turn. The label is |
| 235 | // the user's own text (raw, falling back to the expanded input) — the |
| 236 | // composed provider input carries transient prefab blocks that must never |
| 237 | // surface in the rewind picker or be prefilled into the composer. |
| 238 | if !turn.synthetic { |
| 239 | c.beginCheckpoint(ctx, firstNonEmpty(turn.raw, turn.input)) |
| 240 | } |
| 241 | if c.guardianSess != nil { |
| 242 | c.guardianSess.ResetTurn() |
| 243 | } |
| 244 | // UserPromptSubmit / Stop hooks bracket the whole turn (incl. the plan |
| 245 | // research + approved-execution sub-turns below): a gating UserPromptSubmit |
| 246 | // aborts before any model call; Stop fires once when the turn returns. |
| 247 | if c.hooks.Enabled() { |
| 248 | c.mu.Lock() |
| 249 | c.turn++ |
| 250 | turn := c.turn |
| 251 | c.mu.Unlock() |
| 252 | if block, _ := c.hooks.PromptSubmit(ctx, input, turn); block { |
| 253 | return nil // the hook's notify callback already surfaced the reason |
| 254 | } |
| 255 | defer func() { c.hooks.StopResult(context.Background(), lastAssistantText(c.History()), turn, err) }() |
| 256 | } |
| 257 | marker = c.markInFlightTurn(startMessages, !turn.synthetic) |
| 258 | ctx = c.withTurnContext(ctx, !turn.synthetic) |
| 259 | if turn.goalRound != nil { |
| 260 | if authority, ok := c.goalAuthorityForRound(turn.goalRound); ok { |
| 261 | ctx = tool.WithGoalLifecycle(ctx, c, authority) |
| 262 | } |
| 263 | } else if !turn.synthetic { |
| 264 | if authority, ok := c.directHumanGoalAuthority(); ok { |
| 265 | ctx = tool.WithGoalLifecycle(ctx, c, authority) |
| 266 | } |
| 267 | } |
| 268 | ctx = c.withPlannerTurnMetadata(ctx, turn.raw, turn.synthetic, startMessages) |
| 269 | modelInput := input |
| 270 | if !turn.synthetic { |
| 271 | modelInput = c.withCapabilityRoute(ctx, input, turn.raw) |
| 272 | } |
| 273 | modelInput, ctx, err = c.prepareVisionTurn(ctx, modelInput, imageCandidates) |
| 274 | if err != nil { |
| 275 | return err |
| 276 | } |
| 277 | err = c.runModelTurn(ctx, modelInput) |
| 278 | if err != nil { |
| 279 | fallback := persistedUserTurn(input, turn.raw, userImages, time.Now().UnixMilli()) |
| 280 | fallback.ID = userMessageID |
| 281 | // When the user explicitly cancels, keep the real prompt and any fully |
| 282 | // paired tool work. Partial reasoning/output remains durable for display |
| 283 | // but is marked local-only, and a bounded recovery summary is folded into |
| 284 | // the next real user turn (#5499, #6680). |
| 285 | if errors.Is(err, context.Canceled) && c.CancelRequested() { |
| 286 | if turn.synthetic { |
| 287 | c.stripInterruptedSyntheticTurnMessagesAfter(startMessages) |
| 288 | } else { |
| 289 | c.stripCancelledVisibleTurnMessagesAfterWithFallback(startMessages, fallback) |
| 290 | } |
| 291 | } else if !turn.synthetic && c.hasInterruptedDisplayAfter(startMessages, fallback) { |
| 292 | // Provider/API failures use the same safe recovery path as an explicit |
| 293 | // stop once the agent has recorded a partial stream. Completed tool |
| 294 | // pairs survive; unsafe stream fragments stay local-only. |
| 295 | c.stripCancelledVisibleTurnMessagesAfterWithFallback(startMessages, fallback) |
| 296 | } |
| 297 | return err |
| 298 | } |
| 299 | return o.executeApprovedPlan(ctx) |
| 300 | } |
| 301 | |
| 302 | func (o *turnOrchestrator) executeApprovedPlan(ctx context.Context) error { |
| 303 | c := o.c |
| 304 | c.mu.Lock() |
| 305 | plan := c.sessionSettings.planMode |
| 306 | c.mu.Unlock() |
| 307 | if !plan { |
| 308 | return nil |
| 309 | } |
| 310 | proposal := lastAssistantText(c.History()) |
| 311 | if proposal == "" { |
| 312 | return nil // no substantive proposal to gate |
| 313 | } |
| 314 | // The plan is already visible as the assistant's answer, so the request |
| 315 | // carries no subject — it's purely the gate. |
| 316 | allow, _, err := c.requestApproval(ctx, planApprovalTool, "", nil) |
| 317 | if err != nil { |
| 318 | return err |
| 319 | } |
| 320 | if !allow { |
| 321 | // The host decides whether denial means "revise and keep planning" or |
| 322 | // "exit without executing" by leaving plan mode on or switching it off. |
| 323 | return nil |
| 324 | } |
| 325 | c.SetPlanMode(false) |
| 326 | execStart := c.sessionMessageCount() |
| 327 | // The plan is the go-ahead: don't re-prompt for each write of the approved |
| 328 | // work. Auto-approve writers for the duration of this execution turn only; a |
| 329 | // later turn (even "continue") falls back to the normal per-tool approval. |
| 330 | c.approval.setPlanAutoApprove(true) |
| 331 | defer c.approval.setPlanAutoApprove(false) |
| 332 | err = func() error { |
| 333 | marker := c.markInFlightTurn(execStart, false) |
| 334 | defer c.finishInFlightTurn(execStart, marker) |
| 335 | return o.runComposedSyntheticTurn(ctx, planApprovedMessage) |
| 336 | }() |
| 337 | if err != nil { |
| 338 | if errors.Is(err, context.Canceled) && c.CancelRequested() { |
| 339 | c.stripInterruptedSyntheticTurnMessagesAfter(execStart) |
| 340 | } |
| 341 | return err |
| 342 | } |
| 343 | return nil |
| 344 | } |
| 345 | |
| 346 | func (o *turnOrchestrator) runGoalLoopWithRawDisplay(ctx context.Context, input, raw, display string) error { |
| 347 | return o.runGoalLoopWithImageRefsRawDisplay(ctx, input, raw, "", display) |
| 348 | } |
| 349 | |
| 350 | func (o *turnOrchestrator) runGoalLoopWithImageRefsRawDisplay(ctx context.Context, input, raw, imageRefs, display string) error { |
| 351 | turn := o.c.prepareOrchestratedTurnImages(orchestratedTurn{input: input, raw: raw, imageRefs: imageRefs, display: display}) |
| 352 | return o.runGoalLoopWithPreparedTurn(ctx, turn) |
| 353 | } |
| 354 | |
| 355 | func (o *turnOrchestrator) runGoalLoopWithFrozenImagesRawDisplay(ctx context.Context, input, raw, display string, images []string) error { |
| 356 | turn := orchestratedTurn{ |
| 357 | input: input, |
| 358 | raw: raw, |
| 359 | display: display, |
| 360 | imageCandidates: append([]string(nil), images...), |
| 361 | imagesResolved: true, |
| 362 | } |
| 363 | if o.c.imageInputEnabled() { |
| 364 | turn.userImages = append([]string(nil), images...) |
| 365 | } |
| 366 | return o.runGoalLoopWithPreparedTurn(ctx, turn) |
| 367 | } |
| 368 | |
| 369 | func (o *turnOrchestrator) runGoalLoopWithPreparedTurn(ctx context.Context, turn orchestratedTurn) error { |
| 370 | // Every accepted input is exactly one top-level turn. Automatic Goal work is |
| 371 | // owned exclusively by goalRoundDriver after the runtime becomes idle. |
| 372 | ctx = agent.WithSubagentImageCandidates(ctx, turn.imageCandidates) |
| 373 | return o.runOrchestratedTurn(ctx, turn) |
| 374 | } |
| 375 | |
| 376 | func (o *turnOrchestrator) runEditedGoalLoopWithRawDisplay(ctx context.Context, input, raw, display, original string) error { |
| 377 | return o.runEditedGoalLoopWithImageRefsRawDisplay(ctx, input, raw, "", display, original) |
| 378 | } |
| 379 | |
| 380 | func (o *turnOrchestrator) runEditedGoalLoopWithImageRefsRawDisplay(ctx context.Context, input, raw, imageRefs, display, original string) error { |
| 381 | turn := o.c.prepareOrchestratedTurnImages(orchestratedTurn{ |
| 382 | input: input, raw: raw, imageRefs: imageRefs, display: display, editedOriginal: original, |
| 383 | }) |
| 384 | ctx = agent.WithSubagentImageCandidates(ctx, turn.imageCandidates) |
| 385 | return o.runOrchestratedTurn(ctx, turn) |
| 386 | } |
| 387 | |
| 388 | func (o *turnOrchestrator) runEditedGoalLoopWithFrozenImagesRawDisplay(ctx context.Context, input, raw, display, original string, images []string) error { |
| 389 | turn := orchestratedTurn{ |
| 390 | input: input, raw: raw, display: display, editedOriginal: original, |
| 391 | imageCandidates: append([]string(nil), images...), imagesResolved: true, |
| 392 | } |
| 393 | if o.c.imageInputEnabled() { |
| 394 | turn.userImages = append([]string(nil), images...) |
| 395 | } |
| 396 | ctx = agent.WithSubagentImageCandidates(ctx, turn.imageCandidates) |
| 397 | return o.runOrchestratedTurn(ctx, turn) |
| 398 | } |
| 399 |