返回 DeepSeek-Reasonix
run_loop.go
根目录 / internal / agent / run_loop.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "strings"
9 "time"
10
11 "reasonix/internal/event"
12 "reasonix/internal/evidence"
13 "reasonix/internal/provider"
14 "reasonix/internal/runtimepolicy"
15 )
16
17 // streamedTurn is one provider completion collected by stream. Keeping the
18 // result together makes the missing-reasoning recovery path explicit: the
19 // first, malformed completion is never committed before a safe replacement is
20 // available, and a failed recovery can still fall back to the complete first
21 // response without re-running any tool.
22 type streamedTurn struct {
23 messageID string
24 displayReasoning string
25 settledAttemptID string
26 settledAttempt int
27 text string
28 reasoning string
29 signature string
30 reasoningID string
31 reasoningStatus string
32 reasoningComplete bool
33 reasoningState provider.ReasoningState
34 thinkingBlocks []provider.ThinkingBlock
35 calls []provider.ToolCall
36 responsesItems []json.RawMessage
37 serverSearch []provider.ServerSearchCall
38 usage *provider.Usage
39 interrupted bool
40 partialToolStarted bool
41 partialCalls []provider.ToolCall
42 maxArgChars int // peak streaming tool-arg size for failed-attempt estimates
43 err error
44 }
45
46 func (s streamedTurn) assistantMessage() provider.Message {
47 return provider.Message{
48 ID: s.messageID,
49 Role: provider.RoleAssistant, Content: s.text, ReasoningContent: s.reasoning,
50 ReasoningState: s.reasoningState, ThinkingBlocks: s.thinkingBlocks,
51 ReasoningSignature: s.signature, ReasoningID: s.reasoningID, ReasoningStatus: s.reasoningStatus,
52 ToolCalls: s.calls, ResponsesItems: s.responsesItems, ServerSearch: s.serverSearch,
53 }
54 }
55
56 // beginRunTurn handles evidence scope, delivery classification, background-job
57 // evidence re-lease, and the initial user-turn persistence. Callers still own
58 // all Run-level defers (workspace lease, evidence commit, delivery checkpoint,
59 // steer queue, active-turn timestamp).
60 func (a *Agent) beginRunTurn(ctx context.Context, input string, pinned pinnedRevisionPlan) (rawInput string, state *turnRuntime, err error) {
61 rawInput = RawUserInput(ctx, input)
62 providerInput := input
63 // A fresh user turn starts from zeroed per-turn host state; the new turn's
64 // values are computed below. Cross-turn state (checkpoint, scope, failure
65 // budgets) lives in taskRuntime and is reconciled there.
66 a.stragglers.drain(ctx, parallelStragglerGrace)
67 a.turn = turnRuntime{}
68 a.reads.runGen++
69 a.reads.tasks = newReadTasks(a.sess.path, a.reads.runGen)
70 a.reads.deliveries = make(map[string]readDelivery)
71 a.reads.visible = nil
72 scope, scoped := DeliveryExecutionScopeFromContext(ctx)
73 if a.task.ledger != nil {
74 switch {
75 case scoped && a.task.scopeID == scope.ID:
76 a.task.ledger.ResetBackgroundLeases()
77 default:
78 a.resetTurnEvidence()
79 }
80 }
81 if scoped {
82 a.task.scopeID = scope.ID
83 } else {
84 a.task.scopeID = ""
85 }
86 a.turn.deliveryScopeActive = scoped
87 if scoped && a.task.checkpoint.ScopeID != scope.ID {
88 a.task.checkpoint = evidence.DeliveryCheckpoint{ScopeID: scope.ID}
89 }
90 a.leasePendingBackgroundEvidence(ctx)
91 // Use the owning task text for explicit action constraints and recovery.
92 // Child framing must not be interpreted as an instruction from the user.
93 a.turn.turnInput = a.classifierTaskText
94 if scoped && strings.TrimSpace(scope.TaskText) != "" {
95 a.turn.turnInput = scope.TaskText
96 } else if strings.TrimSpace(a.turn.turnInput) == "" {
97 a.turn.turnInput = rawInput
98 }
99 a.turn.recoveryTaskSummary = boundedRecoveryTaskSummary(a.turn.turnInput)
100 if constraints, ok := runtimepolicy.FromContext(ctx); ok {
101 a.turn.constraints = constraints
102 } else {
103 a.turn.constraints = runtimepolicy.ParseConstraints(runtimepolicy.StripQuotedConstraints(a.turn.turnInput))
104 if a.planMode.Load() {
105 a.turn.constraints.PlanModeReadOnly = true
106 a.turn.constraints.ForbidMutation = true
107 }
108 }
109 if inherited, ok := runtimepolicy.InheritedFromContext(ctx); ok && !a.readOnlyExecution {
110 a.turn.constraints = mergeInheritedConstraints(a.turn.constraints, inherited.Constraints)
111 if inherited.PlanReadOnly {
112 a.turn.constraints.PlanModeReadOnly = true
113 a.turn.constraints.ForbidMutation = true
114 }
115 } else if a.inheritedExec != nil && !a.readOnlyExecution {
116 a.turn.constraints = mergeInheritedConstraints(a.turn.constraints, a.inheritedExec.Constraints)
117 if a.inheritedExec.PlanReadOnly {
118 a.turn.constraints.PlanModeReadOnly = true
119 a.turn.constraints.ForbidMutation = true
120 }
121 }
122 a.turn.engine = runtimepolicy.NewEngine(a.turn.constraints)
123 // A cancelled/error turn leaves a provider-excluded recovery record at the
124 // transcript tail. Fold its bounded facts into this new user turn exactly
125 // once; the user's raw text remains the source above.
126 a.ensureUnreplayableHistoryRecovery()
127 providerInput = withInterruptedRecovery(providerInput, a.verifyInterruptedWrites(ctx, a.pendingInterruptedRecovery()))
128 a.task.prepareScope(scoped, scope.ID)
129 a.svc.sink.Emit(event.Event{Kind: event.TurnStarted})
130 a.emitTurnPhase(event.TurnPhaseWorking)
131 input = a.prepareProviderTurn(ctx, providerInput)
132 userCreatedAt := time.Now().UnixMilli()
133 a.activeTurnCreatedAt.Store(userCreatedAt)
134 rawContent := rawInput
135 if rawContent == "" {
136 rawContent = a.turn.turnInput
137 }
138 userMessage := provider.Message{
139 ID: turnUserMessageID(ctx, a.sess.conversation),
140 Role: provider.RoleUser, Origin: inputMessageOrigin(ctx), Content: input, RawContent: rawContent,
141 Images: userImages(ctx), ImageInputs: userImageInputs(ctx), VisionSummary: VisionSummaryFromContext(ctx), CreatedAt: userCreatedAt,
142 }
143 if err := userMessage.ValidateImageFields(); err != nil {
144 return rawInput, nil, err
145 }
146 if err := a.appendPinnedRevisionAndUser(ctx, pinned, userMessage); err != nil {
147 return rawInput, nil, err
148 }
149 emitAdmittedUserMessage(a.svc.sink, userMessage)
150
151 // The loop fields join the classification computed above rather than
152 // opening a second object: one turn, one turnRuntime. The zero values the
153 // old literal spelled out are already there from the reset at the top.
154 state = &a.turn
155 state.input = input
156 state.budget = runBudget{started: time.Now()}
157 return rawInput, state, nil
158 }
159
160 // runToolLoop owns the main tool-round budget and dispatches each streamed
161 // assistant turn into final-response or tool-round handling.
162 func (a *Agent) runToolLoop(ctx context.Context, state *turnRuntime) (runErr error) {
163 releaseMCPListObserver := a.activateMCPListObserver()
164 defer releaseMCPListObserver()
165 ctx = a.withAgentContext(ctx)
166 truncatedRounds := 0
167 for step := 0; state.runMaxSteps <= 0 || step < state.runMaxSteps || state.graceRound; step++ {
168 // Consume a queued steer and persist it to the session so it
169 // survives tab switches and history replay. The model sees it as
170 // guidance (with a prefix), not a new task. One cache miss per
171 // steer is unavoidable — the model must see the new instruction.
172 if text, itemID, ok := a.consumeSteer(); ok {
173 steerMessage := provider.Message{
174 Role: provider.RoleUser, Origin: provider.MessageOriginUser,
175 Content: a.withTurnPreferences(midTurnSteerMessage(text)), RawContent: text,
176 }
177 if err := a.appendCommittedMessages(ctx, "mid-turn-steer", steerMessage); err != nil {
178 return err
179 }
180 a.svc.sink.Emit(event.Event{Kind: event.Steer, Text: text, ItemID: itemID})
181 } else if itemID != "" {
182 // Loader failed after dequeue: durable entry stays for inspection
183 // (unapplied path marks uncertain + pause via the notice sink).
184 a.RecordUnappliedSteer("(body load failed)", itemID)
185 }
186 schemas := a.providerToolSchemas()
187 prefixShape := a.capturePrefixShape(schemas)
188 prevPrefixShape := a.sess.lastPrefixShape
189 if !a.sess.haveLastPrefixShape {
190 prevPrefixShape = prefixShape
191 }
192 // Drain reasons queued since the previous capture (compaction,
193 // snip/prune, rewind, guardian merge) so CompareShape can attribute
194 // any prefix change to the operation that actually caused it, instead
195 // of a generic rewrite signal that also fires on local-only metadata
196 // edits.
197 contentReasons := a.sess.conversation.DrainContentRewriteReasons()
198
199 // Prefix shape is captured once before sampling and frozen for the
200 // whole attempt lifecycle — stream retries must not rewrite session
201 // history mid-round, so the shape stays stable across body replays.
202 streamed := a.streamWithSamplingRecovery(ctx, step+1)
203 text, reasoning, calls, usage := streamed.text, streamed.reasoning, streamed.calls, streamed.usage
204 partialCalls, err := streamed.partialCalls, streamed.err
205 cacheDiagnostics := CompareShape(prevPrefixShape, prefixShape, usage, contentReasons)
206 a.attachSessionContextDiagnostics(&cacheDiagnostics)
207 if err != nil {
208 quote := a.emitTurnUsage(usage, &cacheDiagnostics)
209 a.observeRunBudget(state, usage, quote)
210 if msg, ok := finishReasonMessage(usage); ok {
211 a.svc.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: msg})
212 }
213 // Exhausted stream retries (or a non-retryable error): persist one
214 // bounded LocalOnly recovery record for the next real user message.
215 // Intermediate failed attempts never wrote session state.
216 a.recordInterruptedDisplay(text, reasoning, partialCalls, true, err, state.workDurationMs(), streamed.messageID)
217 // A broken provider stream can otherwise look like a silent hang
218 // followed only by the generic interrupted-turn notice (#9560).
219 if code, msg := streamInterruptNotice(err); msg != "" {
220 a.svc.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Code: code, Text: msg})
221 }
222 return err
223 }
224 a.sess.lastPrefixShape = prefixShape
225 a.sess.haveLastPrefixShape = true
226 quote := a.emitTurnUsage(usage, &cacheDiagnostics)
227 a.observeRunBudget(state, usage, quote)
228 if msg, ok := finishReasonMessage(usage); ok {
229 a.svc.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: msg})
230 }
231
232 // Commit clean terminal attempts, preserving provider reasoning contracts.
233 calls = a.withPreviewFileDiffs(ctx, calls)
234 if err := assignRecoveryCallIDs(calls); err != nil {
235 return err
236 }
237 assistant := streamed.assistantMessage()
238 assistant.ToolCalls = calls
239 assistant.WorkDurationMs = state.workDurationMs()
240 if err := a.appendCommittedMessages(ctx, "assistant-attempt", assistant); err != nil {
241 if errors.Is(err, context.Canceled) {
242 a.recordInterruptedDisplay(text, reasoning, partialCalls, true, err, state.workDurationMs(), streamed.messageID)
243 }
244 return err
245 }
246 a.publishCommittedSample(streamed)
247
248 if len(calls) == 0 {
249 cont, ferr := a.handleFinalResponse(ctx, state, text, reasoning, usage)
250 if !cont {
251 return ferr
252 }
253 continue
254 }
255
256 if usage != nil && usage.FinishReason == "length" {
257 truncatedRounds++
258 if err := a.recordTruncatedToolResults(withMessageIdentity(ctx, streamed.messageID), calls); err != nil {
259 return err
260 }
261 if truncatedRounds > maxStreamRecoveries {
262 return fmt.Errorf("tool arguments remained truncated after three recovery rounds")
263 }
264 continue
265 }
266 truncatedRounds = 0
267
268 // Invariant: executeBatch only ever receives tool calls from a
269 // committed sampling attempt (clean terminal + response intercept).
270 cont, terr := a.handleToolRound(withMessageIdentity(ctx, streamed.messageID), state, step, text, reasoning, calls, usage)
271 if !cont {
272 return terr
273 }
274 }
275 // Only reached when a positive maxSteps guard is configured. The work so far
276 // is already in the session, so the user can just send another message to pick
277 // up where it left off.
278 return a.gracePause(state)
279 }
280
281 func (a *Agent) emitProtocolRetry(attempt int, hasFallback bool) {
282 maxAttempts := 1
283 if hasFallback {
284 maxAttempts = 2
285 }
286 a.svc.sink.Emit(event.Event{
287 Kind: event.Retrying, RetryAttempt: attempt, RetryMax: maxAttempts,
288 RetryScope: event.RetryScopeProtocol,
289 })
290 }
291
292 func (a *Agent) emitStreamAttempt(id string, action event.StreamAttemptAction, attempt int, reason string, err error) {
293 if reason == "" && err != nil {
294 reason = provider.StreamInterruptReason(err)
295 }
296 a.svc.sink.Emit(event.Event{
297 Kind: event.StreamAttempt,
298 MessageID: id,
299 AttemptID: id,
300 StreamAttempt: event.StreamAttemptInfo{
301 ID: id, Action: action, Attempt: attempt, Max: maxSamplingAttempts, Reason: reason,
302 },
303 })
304 }
305
306 func newStreamAttemptID(_ int) string {
307 // A successful attempt retains this local identity when its message is
308 // committed. Failed attempts have distinct identities and cannot alias it.
309 return NewMessageID()
310 }
311
312 // streamRetrySleep is the body-retry backoff. Tests replace it with a no-op so
313 // recovery suites stay fast while production keeps the Codex-shaped delays.
314 var streamRetrySleep = sleepStreamRetryBackoff
315
316 // sleepStreamRetryBackoff waits ~0.5s, 1s, 2s, 4s, 8s with small jitter.
317 // Returns false when ctx is cancelled during the wait.
318 func sleepStreamRetryBackoff(ctx context.Context, attempt int) bool {
319 return recoverySleep(ctx, time.Duration(1<<min(max(attempt-1, 0), 2))*2*time.Second)
320 }
321
322 var recoverySleep = sleepRecovery
323
324 func sleepRecovery(ctx context.Context, delay time.Duration) bool {
325 timer := time.NewTimer(delay)
326 defer timer.Stop()
327 select {
328 case <-ctx.Done():
329 return false
330 case <-timer.C:
331 return true
332 }
333 }
334
335 // handleFinalResponse processes a no-tool assistant turn: recovery pause,
336 // readiness boundary, empty-final retry, executor handoff nudge, steer drain,
337 // and final compaction. cont=true continues the tool loop; cont=false returns
338 // err from Run (err may be nil for a clean final answer).
339 func (a *Agent) handleFinalResponse(ctx context.Context, state *turnRuntime, text, reasoning string, usage *provider.Usage) (cont bool, err error) {
340 if state.graceRound {
341 // Explicit max_steps and spend budgets are user-selected boundaries.
342 // Preserve the summary, then return a resumable pause so Goal does not
343 // immediately open another Run and silently bypass the chosen limit.
344 a.contextManager().ObserveUsage(usage)
345 return false, a.gracePause(state)
346 }
347 if !hasVisibleFinalAnswer(text) {
348 // Harness-style termination accepts a reasoning-only clean stop. Only
349 // explicit internal callers that require visible output retain the
350 // bounded synthetic retry below. A truly empty response is classified
351 // before this function and retried with the frozen provider request.
352 if a.requireVisibleFinal {
353 state.terminal.emptyFinalBlocks++
354 if state.terminal.emptyFinalBlocks >= maxEmptyFinalBlocks {
355 return false, fmt.Errorf("model finished without a visible final answer %d times", state.terminal.emptyFinalBlocks)
356 }
357 a.svc.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeEmptyFinal, Text: emptyFinalNotice(), Detail: emptyFinalNoticeDetail(a.svc.prov.Name(), usage, len(reasoning))})
358 if err := a.appendCommittedMessages(ctx, "empty-final-retry", HostGeneratedUserMessage(a.withTurnPreferences(emptyFinalRetryMessage()))); err != nil {
359 return false, err
360 }
361 a.contextManager().ObserveUsage(usage)
362 return true, nil
363 }
364 }
365 a.emitTurnShadows(a.turn.turnInput)
366 if !a.closeSteerIntakeIfIdle() {
367 return true, nil
368 }
369 // A final-answer turn skips compaction, so a large context
370 // carries into the next turn un-folded and can overflow the model window.
371 // No-op below the trigger, so normal turns keep their warm cache.
372 a.contextManager().ObserveUsage(usage)
373 a.closeTurnPhase()
374 return false, nil // model gave a final answer
375 }
376
377 // handleToolRound executes a tool batch, persists tool messages, handles
378 // cancellation, todo stall tracking, recovery finalization pause, and the
379 // max-steps grace round. cont=true continues the tool loop; cont=false returns
380 // err from Run.
381 func (a *Agent) handleToolRound(ctx context.Context, state *turnRuntime, step int, text, reasoning string, calls []provider.ToolCall, usage *provider.Usage) (cont bool, err error) {
382 state.terminal.emptyFinalBlocks = 0
383 state.usedAnyTool = true
384
385 boundaryFinalizer := a.allowsBoundaryTurnFinalizer(ctx, state, calls)
386 if boundaryErr, stop := a.stopUnexecutedBoundaryCalls(ctx, state, calls, usage); stop {
387 return false, boundaryErr
388 }
389
390 // The phase pair around the batch is what makes the accounting mean its
391 // names: it bills this round's wait to the provider and the batch to tools.
392 a.emitTurnPhase(event.TurnPhaseChecking)
393 batch := a.executeBatch(ctx, state, calls)
394 a.emitTurnPhase(event.TurnPhaseWorking)
395 if batch.err != nil {
396 // Any completed results are already stored; a failed durability barrier
397 // prevents starting the next tool.
398 return false, batch.err
399 }
400 if a.successfulTurnFinalizer(ctx, calls, batch) {
401 // submit_plan is the planner's data-bearing final answer. Its paired tool
402 // result is stored, so another acknowledgement adds no host value and can
403 // turn a valid bounded plan into a max-steps pause.
404 a.contextManager().ObserveUsage(usage)
405 a.closeTurnPhase()
406 return false, nil
407 }
408 if boundaryFinalizer {
409 // The one allowed boundary finalizer ran but was rejected or blocked.
410 // Preserve the one-grace-round contract instead of opening an unbounded
411 // loop of malformed terminal submissions.
412 a.contextManager().ObserveUsage(usage)
413 return false, a.gracePause(state)
414 }
415 // The prompt only grows from here; compact before the next turn so it
416 // stays within the model's window.
417 a.contextManager().ObserveUsage(usage)
418
419 // Spend is checked before rounds: it is the axis a runaway is actually
420 // reported in, so on the turns both would catch it should be the one named.
421 if axis, detail := a.task.budget.exceeded(a.taskBudgetLimit(ctx)); axis != "" {
422 if err := a.armFinalizationRound(ctx, state, landCause{kind: "task_budget", axis: axis, detail: detail}); err != nil {
423 return false, err
424 }
425 return true, nil
426 }
427 if state.runMaxSteps > 0 && step+1 >= state.runMaxSteps {
428 if err := a.armFinalizationRound(ctx, state, landCause{kind: "max_steps", detail: fmt.Sprintf(
429 "budget (%s=%d) exhausted: one grace round to finalize", state.runMaxStepsKey, state.runMaxSteps)}); err != nil {
430 return false, err
431 }
432 }
433 return true, nil
434 }
435
436 func (a *Agent) pairUnexecutedGraceCalls(ctx context.Context, calls []provider.ToolCall, msg string) error {
437 messages := make([]provider.Message, 0, len(calls))
438 for _, call := range calls {
439 messages = append(messages, provider.Message{Role: provider.RoleTool, Content: msg, ToolCallID: call.ID, Name: call.Name})
440 }
441 return a.appendCommittedMessages(ctx, "unexecuted-grace-tools", messages...)
442 }
443
444 func (a *Agent) publishCommittedSample(streamed streamedTurn) {
445 // Publish settlement only after the complete message is accepted by
446 // the business log. Recovery must never observe an end without a result.
447 if streamed.text != "" || streamed.displayReasoning != "" {
448 a.svc.sink.Emit(event.Event{Kind: event.Message, MessageID: streamed.messageID, AttemptID: streamed.messageID,
449 Text: DisplayAssistantText(streamed.text), Reasoning: streamed.displayReasoning})
450 }
451 if streamed.settledAttemptID != "" {
452 a.emitStreamAttempt(streamed.settledAttemptID, event.StreamAttemptCommit, streamed.settledAttempt, "", nil)
453 }
454 }
455
455 lines GO