| 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 |