返回 DeepSeek-Reasonix
turn_orchestrator.go
根目录 / internal / control / turn_orchestrator.go
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
399 lines GO