mirror of
https://github.com/coder/coder.git
synced 2026-09-22 21:22:17 +08:00
Addresses the deferred `coderd/x/chatd/generation.go` review comments from PR #26109: [required generation dependencies](https://github.com/coder/coder/pull/26109#discussion_r3380311853), [scoped chat variables](https://github.com/coder/coder/pull/26109#discussion_r3387161874), [generation state error handling](https://github.com/coder/coder/pull/26109#discussion_r3387191468), [generation attempt return values](https://github.com/coder/coder/pull/26109#discussion_r3387251382), [generation fence verification](https://github.com/coder/coder/pull/26109#discussion_r3387288234), and [chatdebug outcome recording](https://github.com/coder/coder/pull/26109#discussion_r3387544273). This makes generation task dependencies explicit, packages generation attempt episode state into a struct, and centralizes generation task fence checks for generation transitions. Generated by Coder Agents, closely reviewed by Hugo.
1046 lines
38 KiB
Go
1046 lines
38 KiB
Go
package chatd
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"charm.land/fantasy"
|
|
"github.com/google/uuid"
|
|
"github.com/sqlc-dev/pqtype"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chaterror"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chatloop"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chatretry"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/messagepartbuffer"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
// generationPrepareInput contains the committed state used to prepare one
|
|
// generation action.
|
|
type generationPrepareInput struct {
|
|
Chat database.Chat
|
|
Messages []database.ChatMessage
|
|
}
|
|
|
|
// generationPrepared contains the side-effect inputs for a generation task.
|
|
type generationPrepared struct {
|
|
Chat database.Chat
|
|
Messages []database.ChatMessage
|
|
|
|
Model fantasy.LanguageModel
|
|
Prompt []fantasy.Message
|
|
Tools []fantasy.AgentTool
|
|
ActiveTools []string
|
|
ProviderTools []chatloop.ProviderTool
|
|
ModelRoute aiGatewayModelRoute
|
|
ModelBuildOptions modelBuildOptions
|
|
|
|
// ResolvedProvider is the configured provider identity used to label
|
|
// user-facing errors. See chatloop.GenerateAssistantOptions.ErrorProvider.
|
|
ResolvedProvider string
|
|
|
|
ModelConfigID uuid.UUID
|
|
ModelConfig codersdk.ChatModelCallConfig
|
|
ProviderOptions fantasy.ProviderOptions
|
|
ContextLimitFallback int64
|
|
|
|
DynamicToolNames map[string]bool
|
|
StopAfterTools map[string]struct{}
|
|
ExclusiveToolNames map[string]bool
|
|
BuiltinToolNames map[string]bool
|
|
ToolNameToConfigID map[string]uuid.UUID
|
|
|
|
MaxSteps int
|
|
Compaction *generationCompaction
|
|
// Cleanup is always non-nil when prepareGeneration succeeds.
|
|
Cleanup func()
|
|
|
|
Debug *generationDebug
|
|
}
|
|
|
|
// generationCompaction contains compaction inputs prepared for generation.
|
|
type generationCompaction struct {
|
|
Required bool
|
|
Options chatloop.GenerateCompactionOptions
|
|
}
|
|
|
|
type generationDebug struct {
|
|
Enabled bool
|
|
Service *chatdebug.Service
|
|
Provider string
|
|
Model string
|
|
TriggerMessageID int64
|
|
HistoryTipMessageID int64
|
|
TriggerLabel string
|
|
ModelConfig database.ChatModelConfig
|
|
}
|
|
|
|
// generationOutcome describes a completed generation outcome.
|
|
type generationOutcome struct {
|
|
Chat database.Chat
|
|
Kind runnerActionKind
|
|
WatchEventKind codersdk.ChatWatchEventKind
|
|
LastError string
|
|
PromotedMessageID int64
|
|
InsertedMessages []runnerActionMessage
|
|
}
|
|
|
|
type generationActionKind string
|
|
|
|
const (
|
|
generationActionExecuteLocalTools generationActionKind = "execute_local_tools"
|
|
generationActionEnterRequiresAction generationActionKind = "enter_requires_action"
|
|
generationActionFinishTurn generationActionKind = "finish_turn"
|
|
generationActionCompact generationActionKind = "compact"
|
|
generationActionGenerateAssistant generationActionKind = "generate_assistant"
|
|
)
|
|
|
|
type generationFinishReason string
|
|
|
|
const (
|
|
generationFinishReasonStopAfterTool generationFinishReason = "stop_after_tool"
|
|
generationFinishReasonComplete generationFinishReason = "complete"
|
|
generationFinishReasonMaxSteps generationFinishReason = "max_steps"
|
|
)
|
|
|
|
var errCompactionStillOverLimit = chaterror.WithClassification(
|
|
xerrors.New("compaction left the chat above the compaction limit"),
|
|
chaterror.ClassifiedError{
|
|
Message: "Conversation compaction could not reduce the history below the configured limit. Raise the compaction limit in settings, or start a new conversation.",
|
|
Kind: codersdk.ChatErrorKindConfig,
|
|
},
|
|
)
|
|
|
|
type generationDecision struct {
|
|
kind generationActionKind
|
|
localToolCalls []fantasy.ToolCallContent
|
|
pendingDynamicToolCalls []pendingDynamicToolCall
|
|
finishReason generationFinishReason
|
|
promotedMessageID int64
|
|
}
|
|
|
|
type generationRetryDecision struct {
|
|
retry bool
|
|
generationAttempt int64
|
|
delay time.Duration
|
|
}
|
|
|
|
var errRetryStateDecisionOnly = xerrors.New("retry state decision only")
|
|
|
|
// errTerminalGeneration marks a prepare or decide failure as terminal: a
|
|
// deterministic error where retrying cannot help. The generation loop
|
|
// finishes the turn with an error instead of retrying when an error
|
|
// unwraps to this sentinel.
|
|
var errTerminalGeneration = xerrors.New("terminal generation error")
|
|
|
|
type terminalGenerationError struct{ err error }
|
|
|
|
func (e terminalGenerationError) Error() string { return e.err.Error() }
|
|
|
|
func (e terminalGenerationError) Unwrap() error { return errors.Join(errTerminalGeneration, e.err) }
|
|
|
|
// terminalGeneration wraps err so the prepare/decide retry loop stops
|
|
// immediately and finishes the turn with an error.
|
|
func terminalGeneration(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
return terminalGenerationError{err: err}
|
|
}
|
|
|
|
func isTerminalGeneration(err error) bool {
|
|
return errors.Is(err, errTerminalGeneration)
|
|
}
|
|
|
|
type generationDecisionInput struct {
|
|
chat database.Chat
|
|
messages []database.ChatMessage
|
|
dynamicToolNames map[string]bool
|
|
exclusiveToolNames map[string]bool
|
|
stopAfterTools map[string]struct{}
|
|
maxSteps int
|
|
compactionEnabled bool
|
|
compactionNeeded bool
|
|
compactionThresholdPercent int32
|
|
compactionContextLimit int64
|
|
}
|
|
|
|
func decideGenerationAction(input generationDecisionInput) (generationDecision, error) {
|
|
localCalls, dynamicCalls, err := unresolvedToolCallsFromHistory(input.messages, input.dynamicToolNames)
|
|
if err != nil {
|
|
return generationDecision{}, err
|
|
}
|
|
if len(localCalls) > 0 {
|
|
if len(dynamicCalls) > 0 && hasExclusiveToolCall(localCalls, input.exclusiveToolNames) {
|
|
for _, dynamicCall := range dynamicCalls {
|
|
localCalls = append(localCalls, fantasy.ToolCallContent{
|
|
ToolCallID: dynamicCall.ToolCallID,
|
|
ToolName: dynamicCall.ToolName,
|
|
Input: dynamicCall.Args,
|
|
})
|
|
}
|
|
dynamicCalls = nil
|
|
}
|
|
return generationDecision{kind: generationActionExecuteLocalTools, localToolCalls: localCalls, pendingDynamicToolCalls: dynamicCalls}, nil
|
|
}
|
|
if len(dynamicCalls) > 0 {
|
|
return generationDecision{kind: generationActionEnterRequiresAction, pendingDynamicToolCalls: dynamicCalls}, nil
|
|
}
|
|
|
|
stopAfter, err := historyHasStopAfterToolResult(input.messages, input.stopAfterTools)
|
|
if err != nil {
|
|
return generationDecision{}, err
|
|
}
|
|
if stopAfter {
|
|
return generationDecision{kind: generationActionFinishTurn, finishReason: generationFinishReasonStopAfterTool}, nil
|
|
}
|
|
complete, err := currentHistoryComplete(input.messages)
|
|
if err != nil {
|
|
return generationDecision{}, err
|
|
}
|
|
if complete {
|
|
return generationDecision{kind: generationActionFinishTurn, finishReason: generationFinishReasonComplete}, nil
|
|
}
|
|
if input.maxSteps > 0 && currentTurnStepCount(input.messages) >= input.maxSteps {
|
|
return generationDecision{kind: generationActionFinishTurn, finishReason: generationFinishReasonMaxSteps}, nil
|
|
}
|
|
compactionRequirement := compactionRequirementNotNeeded
|
|
if input.compactionEnabled && input.compactionNeeded {
|
|
compactionRequirement = compactionRequirementNeeded
|
|
}
|
|
switch compactionStatusFromHistory(input.messages, compactionRequirement, input.compactionThresholdPercent, input.compactionContextLimit) {
|
|
case compactionStatusNeeded:
|
|
return generationDecision{kind: generationActionCompact}, nil
|
|
case compactionStatusAfterCompaction:
|
|
return generationDecision{kind: generationActionGenerateAssistant}, nil
|
|
case compactionStatusStillOverLimit:
|
|
return generationDecision{}, terminalGeneration(errCompactionStillOverLimit)
|
|
case compactionStatusNotNeeded:
|
|
return generationDecision{kind: generationActionGenerateAssistant}, nil
|
|
default:
|
|
return generationDecision{}, terminalGeneration(xerrors.New("unknown compaction status"))
|
|
}
|
|
}
|
|
|
|
func generationCompactionThreshold(compaction *generationCompaction) int32 {
|
|
if compaction == nil {
|
|
return 0
|
|
}
|
|
return compaction.Options.ThresholdPercent
|
|
}
|
|
|
|
func unresolvedToolCallsFromHistory(
|
|
messages []database.ChatMessage,
|
|
dynamicToolNames map[string]bool,
|
|
) ([]fantasy.ToolCallContent, []pendingDynamicToolCall, error) {
|
|
assistantIndex := lastMessageIndex(messages, func(msg database.ChatMessage) bool {
|
|
return msg.Role == database.ChatMessageRoleAssistant
|
|
})
|
|
if assistantIndex == -1 {
|
|
return nil, nil, nil
|
|
}
|
|
assistantParts, err := chatprompt.ParseContent(messages[assistantIndex])
|
|
if err != nil {
|
|
return nil, nil, xerrors.Errorf("parse assistant message: %w", err)
|
|
}
|
|
handled, err := handledToolCallIDs(messages[assistantIndex+1:])
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
localCalls := make([]fantasy.ToolCallContent, 0)
|
|
dynamicCalls := make([]pendingDynamicToolCall, 0)
|
|
for _, part := range assistantParts {
|
|
if part.Type != codersdk.ChatMessagePartTypeToolCall || part.ProviderExecuted || handled[part.ToolCallID] {
|
|
continue
|
|
}
|
|
if dynamicToolNames[part.ToolName] {
|
|
dynamicCalls = append(dynamicCalls, pendingDynamicToolCall{
|
|
ToolCallID: part.ToolCallID,
|
|
ToolName: part.ToolName,
|
|
Args: string(part.Args),
|
|
})
|
|
continue
|
|
}
|
|
localCalls = append(localCalls, fantasy.ToolCallContent{
|
|
ToolCallID: part.ToolCallID,
|
|
ToolName: part.ToolName,
|
|
Input: string(part.Args),
|
|
ProviderExecuted: part.ProviderExecuted,
|
|
})
|
|
}
|
|
return localCalls, dynamicCalls, nil
|
|
}
|
|
|
|
func hasExclusiveToolCall(toolCalls []fantasy.ToolCallContent, exclusiveToolNames map[string]bool) bool {
|
|
if len(exclusiveToolNames) == 0 {
|
|
return false
|
|
}
|
|
for _, toolCall := range toolCalls {
|
|
if exclusiveToolNames[toolCall.ToolName] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskStartInput) error {
|
|
machine := chatstate.NewChatMachine(s.opts.Store, s.opts.Pubsub, input.ChatID)
|
|
for {
|
|
chat, messages, err := loadGenerationState(ctx, machine, input)
|
|
if err != nil {
|
|
return xerrors.Errorf("load generation state: %w", err)
|
|
}
|
|
prepareInput := generationPrepareInput{
|
|
Chat: chat,
|
|
Messages: messages,
|
|
}
|
|
prepared, err := retryGenerationPhase(ctx, s, "prepare", func() (generationPrepared, error) {
|
|
return s.server.prepareGeneration(ctx, prepareInput)
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, errTaskExpectedExit) || errors.Is(err, errTaskRetryable) {
|
|
return xerrors.Errorf("prepare generation: %w", err)
|
|
}
|
|
return s.finishGenerationError(ctx, machine, input, err, generationAttemptNotRequired)
|
|
}
|
|
cleanup := prepared.Cleanup
|
|
decision, err := retryGenerationPhase(ctx, s, "decide", func() (generationDecision, error) {
|
|
return decideGenerationAction(generationDecisionInput{
|
|
chat: prepared.Chat,
|
|
messages: prepared.Messages,
|
|
dynamicToolNames: prepared.DynamicToolNames,
|
|
exclusiveToolNames: prepared.ExclusiveToolNames,
|
|
stopAfterTools: prepared.StopAfterTools,
|
|
maxSteps: prepared.MaxSteps,
|
|
compactionEnabled: prepared.Compaction != nil,
|
|
compactionNeeded: prepared.Compaction != nil && prepared.Compaction.Required,
|
|
compactionThresholdPercent: generationCompactionThreshold(prepared.Compaction),
|
|
compactionContextLimit: prepared.ContextLimitFallback,
|
|
})
|
|
})
|
|
if err != nil {
|
|
cleanup()
|
|
if errors.Is(err, errTaskExpectedExit) || errors.Is(err, errTaskRetryable) {
|
|
return xerrors.Errorf("decide generation: %w", err)
|
|
}
|
|
if errors.Is(err, errCompactionStillOverLimit) && prepared.Compaction != nil {
|
|
s.server.metrics.RecordCompaction(
|
|
compactionProvider(prepared.Compaction.Options),
|
|
compactionModel(prepared.Compaction.Options),
|
|
false,
|
|
errCompactionStillOverLimit,
|
|
)
|
|
}
|
|
return s.finishGenerationError(ctx, machine, input, err, generationAttemptNotRequired)
|
|
}
|
|
|
|
var actionErr error
|
|
switch decision.kind {
|
|
case generationActionEnterRequiresAction:
|
|
cleanup()
|
|
return s.enterRequiresAction(ctx, machine, input)
|
|
case generationActionFinishTurn:
|
|
cleanup()
|
|
return s.finishGenerationTurn(ctx, machine, input, decision, generationAttemptNotRequired)
|
|
case generationActionGenerateAssistant:
|
|
actionErr = s.generateAssistant(ctx, machine, input, prepared)
|
|
case generationActionExecuteLocalTools:
|
|
actionErr = s.executeLocalTools(ctx, machine, input, prepared, decision)
|
|
case generationActionCompact:
|
|
actionErr = s.generateCompaction(ctx, machine, input, prepared)
|
|
default:
|
|
return s.finishGenerationError(ctx, machine, input, xerrors.Errorf("unknown generation action %q", decision.kind), generationAttemptNotRequired)
|
|
}
|
|
cleanup()
|
|
if actionErr == nil {
|
|
return nil
|
|
}
|
|
// Task cancellation is handled by the runner, not here.
|
|
if ctx.Err() != nil && errors.Is(actionErr, context.Canceled) {
|
|
return errors.Join(errTaskExpectedExit, xerrors.Errorf("generation action: %w", actionErr), ctx.Err())
|
|
}
|
|
if errors.Is(actionErr, chatloop.ErrInterrupted) {
|
|
return errors.Join(errTaskExpectedExit, xerrors.Errorf("generation action: %w", actionErr))
|
|
}
|
|
if errors.Is(actionErr, errTaskExpectedExit) {
|
|
return xerrors.Errorf("generation action: %w", actionErr)
|
|
}
|
|
classified := chaterror.Classify(actionErr)
|
|
if classified.Retryable {
|
|
action := decision.kind
|
|
decision, err := s.recordGenerationRetry(ctx, machine, input, classified)
|
|
if err != nil {
|
|
return xerrors.Errorf("record generation retry: %w", err)
|
|
}
|
|
if decision.retry {
|
|
s.opts.Logger.Warn(ctx, "chat generation retrying",
|
|
slog.F("chat_id", input.ChatID),
|
|
slog.F("worker_id", input.WorkerID),
|
|
slog.F("action", action),
|
|
slog.F("generation_attempt", decision.generationAttempt),
|
|
slog.F("delay", decision.delay),
|
|
slog.F("error_kind", classified.Kind),
|
|
slog.F("provider", classified.Provider),
|
|
slog.F("status_code", classified.StatusCode),
|
|
slogError(actionErr),
|
|
)
|
|
if err := s.waitGenerationRetry(ctx, decision.delay); err != nil {
|
|
return xerrors.Errorf("wait generation retry: %w", err)
|
|
}
|
|
continue
|
|
}
|
|
return s.finishGenerationError(ctx, machine, input, actionErr, requireGenerationAttempt(decision.generationAttempt))
|
|
}
|
|
return s.finishGenerationError(ctx, machine, input, actionErr, generationAttemptNotRequired)
|
|
}
|
|
}
|
|
|
|
func loadGenerationState(
|
|
ctx context.Context,
|
|
machine *chatstate.ChatMachine,
|
|
input chatWorkerTaskStartInput,
|
|
) (database.Chat, []database.ChatMessage, error) {
|
|
var chat database.Chat
|
|
var messages []database.ChatMessage
|
|
err := machine.ReadLock(ctx, func(store database.Store) error {
|
|
loadedChat, err := loadChatForTask(ctx, store, input, database.ChatStatusRunning, taskFenceOptions{requireHistory: true})
|
|
if err != nil {
|
|
return xerrors.Errorf("load chat for task: %w", err)
|
|
}
|
|
loadedMessages, err := store.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
|
|
ChatID: input.ChatID,
|
|
AfterID: 0,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("load chat messages: %w", err)
|
|
}
|
|
chat = loadedChat
|
|
messages = loadedMessages
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return database.Chat{}, nil, normalizeTaskInfrastructureError(err, "lock chat for generation")
|
|
}
|
|
return chat, messages, nil
|
|
}
|
|
|
|
func (*taskStarter) recordGenerationRetry(
|
|
ctx context.Context,
|
|
machine *chatstate.ChatMachine,
|
|
input chatWorkerTaskStartInput,
|
|
classified chaterror.ClassifiedError,
|
|
) (generationRetryDecision, error) {
|
|
var decision generationRetryDecision
|
|
var payload *codersdk.ChatStreamRetry
|
|
err := machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error {
|
|
chat, err := loadChatForTask(ctx, store, input, database.ChatStatusRunning, taskFenceOptions{requireHistory: true})
|
|
if err != nil {
|
|
return xerrors.Errorf("load chat for task: %w", err)
|
|
}
|
|
decision.generationAttempt = chat.GenerationAttempt
|
|
if chat.GenerationAttempt <= 0 || chat.GenerationAttempt >= int64(chatretry.MaxAttempts) {
|
|
decision.retry = false
|
|
return errRetryStateDecisionOnly
|
|
}
|
|
|
|
attempt := int(chat.GenerationAttempt)
|
|
delay := chatretry.Delay(attempt - 1)
|
|
if classified.RetryAfter > delay {
|
|
delay = classified.RetryAfter
|
|
}
|
|
decision.retry = true
|
|
decision.delay = delay
|
|
|
|
payload = chaterror.StreamRetryPayload(attempt, delay, classified)
|
|
if payload == nil {
|
|
return errRetryStateDecisionOnly
|
|
}
|
|
encoded, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return xerrors.Errorf("marshal retry state: %w", err)
|
|
}
|
|
_, err = tx.RecordRetryState(chatstate.RecordRetryStateInput{
|
|
RetryState: pqtype.NullRawMessage{RawMessage: encoded, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("record retry state: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
if errors.Is(err, errRetryStateDecisionOnly) {
|
|
return decision, nil
|
|
}
|
|
if err != nil {
|
|
return generationRetryDecision{}, normalizeTaskTransitionError(err, "record retry state")
|
|
}
|
|
return decision, nil
|
|
}
|
|
|
|
func (s *taskStarter) waitGenerationRetry(ctx context.Context, delay time.Duration) error {
|
|
timer := s.opts.Clock.NewTimer(delay, "chatworker", "generation-retry")
|
|
defer timer.Stop()
|
|
select {
|
|
case <-timer.C:
|
|
return nil
|
|
case <-ctx.Done():
|
|
return errors.Join(errTaskExpectedExit, xerrors.Errorf("wait generation retry: %w", ctx.Err()))
|
|
}
|
|
}
|
|
|
|
const (
|
|
// generationPhaseMaxAttempts bounds how many times prepareGeneration
|
|
// and decideGenerationAction run before the turn finishes with an
|
|
// error. Both phases are retried because prepareGeneration performs
|
|
// I/O (DB reads, MCP connects, workspace dials) that can fail
|
|
// transiently.
|
|
generationPhaseMaxAttempts = 3
|
|
// generationPhaseBaseBackoff is the delay before the first retry. It
|
|
// doubles on each subsequent attempt.
|
|
generationPhaseBaseBackoff = 200 * time.Millisecond
|
|
)
|
|
|
|
func generationPhaseBackoff(attempt int) time.Duration {
|
|
d := generationPhaseBaseBackoff
|
|
for range attempt {
|
|
d *= 2
|
|
}
|
|
return d
|
|
}
|
|
|
|
// retryGenerationPhase runs fn up to generationPhaseMaxAttempts times. It
|
|
// returns early on success or on a terminal error (see terminalGeneration).
|
|
// Non-terminal errors are retried with exponential backoff. Context
|
|
// cancellation returns errTaskExpectedExit so shutdown does not write an
|
|
// error state. Task timeouts return a retryable task error so the runner can
|
|
// start a fresh attempt. When every attempt fails, the last error is returned.
|
|
func retryGenerationPhase[T any](ctx context.Context, starter *taskStarter, phase string, fn func() (T, error)) (T, error) {
|
|
var zero T
|
|
var lastErr error
|
|
for attempt := 0; attempt < generationPhaseMaxAttempts; attempt++ {
|
|
result, err := fn()
|
|
if err == nil {
|
|
return result, nil
|
|
}
|
|
if isTerminalGeneration(err) {
|
|
return zero, xerrors.Errorf("retryGenerationPhase terminal error: %w", err)
|
|
}
|
|
if ctx.Err() != nil {
|
|
return zero, errors.Join(errTaskExpectedExit, xerrors.Errorf("retryGenerationPhase %s: %w", phase, ctx.Err()))
|
|
}
|
|
lastErr = err
|
|
if attempt < generationPhaseMaxAttempts-1 {
|
|
delay := generationPhaseBackoff(attempt)
|
|
starter.opts.Logger.Warn(ctx, "chat generation phase retrying",
|
|
slog.F("phase", phase),
|
|
slog.F("attempt", attempt+1),
|
|
slog.F("max_attempts", generationPhaseMaxAttempts),
|
|
slog.F("delay", delay),
|
|
slogError(err),
|
|
)
|
|
if waitErr := starter.waitGenerationPhaseBackoff(ctx, delay); waitErr != nil {
|
|
return zero, waitErr
|
|
}
|
|
}
|
|
}
|
|
return zero, lastErr
|
|
}
|
|
|
|
func (s *taskStarter) waitGenerationPhaseBackoff(ctx context.Context, delay time.Duration) error {
|
|
timer := s.opts.Clock.NewTimer(delay, "chatworker", "generation-phase-retry")
|
|
defer timer.Stop()
|
|
select {
|
|
case <-timer.C:
|
|
return nil
|
|
case <-ctx.Done():
|
|
return errors.Join(errTaskExpectedExit, xerrors.Errorf("wait generation phase backoff: %w", ctx.Err()))
|
|
}
|
|
}
|
|
|
|
func (s *taskStarter) generateAssistant(
|
|
ctx context.Context,
|
|
machine *chatstate.ChatMachine,
|
|
input chatWorkerTaskStartInput,
|
|
prepared generationPrepared,
|
|
) error {
|
|
attempt, err := s.beginGenerationAttempt(ctx, machine, input)
|
|
if err != nil {
|
|
return xerrors.Errorf("begin generation attempt: %w", err)
|
|
}
|
|
defer attempt.closeEpisode()
|
|
runCtx := input.DebugTurn.Ensure(ctx, prepared.Chat, prepared.Debug)
|
|
outcome, err := chatloop.GenerateAssistant(runCtx, chatloop.GenerateAssistantOptions{
|
|
Model: prepared.Model,
|
|
ErrorProvider: prepared.ResolvedProvider,
|
|
Messages: prepared.Prompt,
|
|
Tools: prepared.Tools,
|
|
ActiveTools: prepared.ActiveTools,
|
|
ProviderTools: prepared.ProviderTools,
|
|
ContextLimitFallback: prepared.ContextLimitFallback,
|
|
ModelConfig: prepared.ModelConfig,
|
|
ProviderOptions: prepared.ProviderOptions,
|
|
PublishMessagePart: attempt.publish,
|
|
Logger: s.opts.Logger,
|
|
Clock: s.opts.Clock,
|
|
Metrics: s.server.metrics,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("generate assistant: %w", err)
|
|
}
|
|
if len(outcome.Step.Content) == 0 {
|
|
return s.finishGenerationTurn(ctx, machine, input, generationDecision{kind: generationActionFinishTurn, finishReason: generationFinishReasonComplete}, requireGenerationAttempt(attempt.number))
|
|
}
|
|
messages, err := buildCommitStepMessages(buildCommitStepMessagesInput{
|
|
modelConfigID: prepared.ModelConfigID,
|
|
modelCallConfig: prepared.ModelConfig,
|
|
step: stepDataFromPersisted(outcome.Step),
|
|
toolNameToConfigID: prepared.ToolNameToConfigID,
|
|
logger: s.opts.Logger,
|
|
contentVersion: chatprompt.CurrentContentVersion,
|
|
})
|
|
if err != nil {
|
|
return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number))
|
|
}
|
|
return s.commitGenerationStep(ctx, machine, input, attempt.number, generationActionGenerateAssistant, messages)
|
|
}
|
|
|
|
func (s *taskStarter) executeLocalTools(
|
|
ctx context.Context,
|
|
machine *chatstate.ChatMachine,
|
|
input chatWorkerTaskStartInput,
|
|
prepared generationPrepared,
|
|
decision generationDecision,
|
|
) error {
|
|
attempt, err := s.beginGenerationAttempt(ctx, machine, input)
|
|
if err != nil {
|
|
return xerrors.Errorf("beginGenerationAttempt: %w", err)
|
|
}
|
|
defer attempt.closeEpisode()
|
|
provider := ""
|
|
modelName := ""
|
|
if prepared.Model != nil {
|
|
provider = prepared.Model.Provider()
|
|
modelName = prepared.Model.Model()
|
|
}
|
|
// Local tool callbacks (e.g. spawn_agent, message_agent) read the
|
|
// active turn's delegated API key ID from the context to route
|
|
// subagent traffic through the AI Gateway. prepareGeneration sets it
|
|
// only on its own context, so re-derive it here for tool execution.
|
|
toolCtx := withActiveTurnAPIKeyID(ctx, prepared.ModelBuildOptions)
|
|
outcome, err := chatloop.ExecuteLocalTools(toolCtx, chatloop.ExecuteLocalToolsOptions{
|
|
Tools: prepared.Tools,
|
|
ActiveTools: prepared.ActiveTools,
|
|
ProviderTools: prepared.ProviderTools,
|
|
ToolCalls: decision.localToolCalls,
|
|
ExclusiveToolNames: prepared.ExclusiveToolNames,
|
|
BuiltinToolNames: prepared.BuiltinToolNames,
|
|
ModelProvider: provider,
|
|
ModelName: modelName,
|
|
ContextLimit: prepared.ContextLimitFallback,
|
|
ToolNameAliases: subagentToolNameAliases,
|
|
PublishMessagePart: attempt.publish,
|
|
Logger: s.opts.Logger,
|
|
Metrics: s.server.metrics,
|
|
Clock: s.opts.Clock,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("execute local tools: %w", err)
|
|
}
|
|
messages, err := buildCommitStepMessages(buildCommitStepMessagesInput{
|
|
modelConfigID: prepared.ModelConfigID,
|
|
modelCallConfig: prepared.ModelConfig,
|
|
step: stepDataFromPersisted(outcome.Step),
|
|
toolNameToConfigID: prepared.ToolNameToConfigID,
|
|
logger: s.opts.Logger,
|
|
contentVersion: chatprompt.CurrentContentVersion,
|
|
})
|
|
if err != nil {
|
|
return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number))
|
|
}
|
|
return s.commitGenerationStep(ctx, machine, input, attempt.number, generationActionExecuteLocalTools, messages)
|
|
}
|
|
|
|
func (s *taskStarter) generateCompaction(
|
|
ctx context.Context,
|
|
machine *chatstate.ChatMachine,
|
|
input chatWorkerTaskStartInput,
|
|
prepared generationPrepared,
|
|
) error {
|
|
attempt, err := s.beginGenerationAttempt(ctx, machine, input)
|
|
if err != nil {
|
|
return xerrors.Errorf("beginGenerationAttempt: %w", err)
|
|
}
|
|
defer attempt.closeEpisode()
|
|
if prepared.Compaction == nil {
|
|
return s.finishGenerationError(ctx, machine, input, xerrors.New("compaction action missing options"), requireGenerationAttempt(attempt.number))
|
|
}
|
|
compactionOpts := prepared.Compaction.Options
|
|
compactionOpts.PublishMessagePart = attempt.publish
|
|
outcome, err := chatloop.GenerateCompaction(ctx, compactionOpts)
|
|
if err != nil {
|
|
s.server.metrics.RecordCompaction(compactionProvider(compactionOpts), compactionModel(compactionOpts), false, err)
|
|
return xerrors.Errorf("generate compaction: %w", err)
|
|
}
|
|
if strings.TrimSpace(outcome.SystemSummary) == "" || strings.TrimSpace(outcome.SummaryReport) == "" {
|
|
err := xerrors.New("compaction produced no summary")
|
|
s.server.metrics.RecordCompaction(compactionProvider(compactionOpts), compactionModel(compactionOpts), false, err)
|
|
return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number))
|
|
}
|
|
messages, err := buildCompactionMessages(buildCompactionMessagesInput{
|
|
modelConfigID: prepared.ModelConfigID,
|
|
activeAPIKeyID: prepared.ModelBuildOptions.ActiveAPIKeyID,
|
|
toolCallID: compactionOpts.ToolCallID,
|
|
toolName: compactionOpts.ToolName,
|
|
compaction: compactionOutcome(outcome),
|
|
contentVersion: chatprompt.CurrentContentVersion,
|
|
})
|
|
if err != nil {
|
|
s.server.metrics.RecordCompaction(compactionProvider(compactionOpts), compactionModel(compactionOpts), false, err)
|
|
return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number))
|
|
}
|
|
err = s.commitGenerationStep(ctx, machine, input, attempt.number, generationActionCompact, stepMessagesForCommit{
|
|
Messages: messages.Messages,
|
|
VisibleIndexes: visibleMessageIndexes(messages.Messages),
|
|
})
|
|
s.server.metrics.RecordCompaction(compactionProvider(compactionOpts), compactionModel(compactionOpts), err == nil, err)
|
|
if err != nil {
|
|
return xerrors.Errorf("commit generation step: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func compactionProvider(opts chatloop.GenerateCompactionOptions) string {
|
|
if opts.Model == nil {
|
|
return ""
|
|
}
|
|
return opts.Model.Provider()
|
|
}
|
|
|
|
func compactionModel(opts chatloop.GenerateCompactionOptions) string {
|
|
if opts.Model == nil {
|
|
return ""
|
|
}
|
|
return opts.Model.Model()
|
|
}
|
|
|
|
// generationAttempt groups the state a generation action needs after
|
|
// recording a new attempt.
|
|
type generationAttempt struct {
|
|
number int64
|
|
// publish streams a message part into the attempt's buffer episode.
|
|
publish func(codersdk.ChatMessageRole, codersdk.ChatMessagePart)
|
|
// closeEpisode closes the attempt's buffer episode. It is always
|
|
// non-nil when beginGenerationAttempt succeeds.
|
|
closeEpisode func()
|
|
}
|
|
|
|
func (s *taskStarter) beginGenerationAttempt(
|
|
ctx context.Context,
|
|
machine *chatstate.ChatMachine,
|
|
input chatWorkerTaskStartInput,
|
|
) (generationAttempt, error) {
|
|
var attempt int64
|
|
var committed database.Chat
|
|
err := machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error {
|
|
if _, err := loadChatForTask(ctx, store, input, database.ChatStatusRunning, taskFenceOptions{requireHistory: true}); err != nil {
|
|
return xerrors.Errorf("load chat for task: %w", err)
|
|
}
|
|
result, err := tx.RecordGenerationAttempt(chatstate.RecordGenerationAttemptInput{})
|
|
if err != nil {
|
|
return xerrors.Errorf("tx.RecordGenerationAttempt: %w", err)
|
|
}
|
|
attempt = result.GenerationAttempt
|
|
committed, err = store.GetChatByID(ctx, input.ChatID)
|
|
if err != nil {
|
|
return xerrors.Errorf("load committed chat: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return generationAttempt{}, normalizeTaskTransitionError(err, "record generation attempt")
|
|
}
|
|
key := messagepartbuffer.Key{
|
|
ChatID: input.ChatID,
|
|
HistoryVersion: committed.HistoryVersion,
|
|
GenerationAttempt: attempt,
|
|
}
|
|
if err := s.opts.MessagePartBuffer.CreateEpisode(key); err != nil && ctx.Err() == nil {
|
|
return generationAttempt{}, taskRetryableError{err: xerrors.Errorf("create message part episode: %w", err)}
|
|
}
|
|
return generationAttempt{
|
|
number: attempt,
|
|
publish: func(role codersdk.ChatMessageRole, part codersdk.ChatMessagePart) {
|
|
_ = s.opts.MessagePartBuffer.AddPart(key, role, part)
|
|
},
|
|
closeEpisode: func() {
|
|
_ = s.opts.MessagePartBuffer.CloseEpisode(key)
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (s *taskStarter) commitGenerationStep(
|
|
ctx context.Context,
|
|
machine *chatstate.ChatMachine,
|
|
input chatWorkerTaskStartInput,
|
|
attempt int64,
|
|
kind generationActionKind,
|
|
messages stepMessagesForCommit,
|
|
) error {
|
|
if len(messages.Messages) == 0 {
|
|
return s.finishGenerationTurn(ctx, machine, input, generationDecision{kind: generationActionFinishTurn, finishReason: generationFinishReasonComplete}, requireGenerationAttempt(attempt))
|
|
}
|
|
var committed database.Chat
|
|
insertedMessages := []runnerActionMessage{}
|
|
err := machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error {
|
|
if _, err := loadChatForGeneration(ctx, store, input, requireGenerationAttempt(attempt)); err != nil {
|
|
return xerrors.Errorf("load chat for generation: %w", err)
|
|
}
|
|
commitResult, err := tx.CommitStep(chatstate.CommitStepInput{Messages: messages.Messages})
|
|
if err != nil {
|
|
return xerrors.Errorf("tx.CommitStep: %w", err)
|
|
}
|
|
insertedMessages = make([]runnerActionMessage, 0, len(commitResult.InsertedMessages))
|
|
for _, msg := range commitResult.InsertedMessages {
|
|
insertedMessages = append(insertedMessages, runnerActionMessage{ID: msg.ID, Role: codersdk.ChatMessageRole(msg.Role)})
|
|
}
|
|
committed, err = store.GetChatByID(ctx, input.ChatID)
|
|
if err != nil {
|
|
return xerrors.Errorf("load committed chat: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return normalizeTaskTransitionError(err, "commit generation step")
|
|
}
|
|
s.routeStateHint(ctx, stateUpdateFromChat(committed))
|
|
return s.afterGenerationOutcome(ctx, generationOutcome{
|
|
Chat: committed,
|
|
Kind: runnerActionKind(kind),
|
|
InsertedMessages: insertedMessages,
|
|
})
|
|
}
|
|
|
|
func (s *taskStarter) enterRequiresAction(
|
|
ctx context.Context,
|
|
machine *chatstate.ChatMachine,
|
|
input chatWorkerTaskStartInput,
|
|
) error {
|
|
var committed database.Chat
|
|
err := machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error {
|
|
if _, err := loadChatForTask(ctx, store, input, database.ChatStatusRunning, taskFenceOptions{requireHistory: true}); err != nil {
|
|
return xerrors.Errorf("load chat for task: %w", err)
|
|
}
|
|
if _, err := tx.EnterRequiresAction(chatstate.EnterRequiresActionInput{}); err != nil {
|
|
return xerrors.Errorf("tx.EnterRequiresAction: %w", err)
|
|
}
|
|
chat, err := store.GetChatByID(ctx, input.ChatID)
|
|
if err != nil {
|
|
return xerrors.Errorf("load committed chat: %w", err)
|
|
}
|
|
committed = chat
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return normalizeTaskTransitionError(err, "enter requires action")
|
|
}
|
|
if err := s.publishWatchAndRoute(ctx, committed, codersdk.ChatWatchEventKindActionRequired); err != nil {
|
|
return xerrors.Errorf("publish watch and route: %w", err)
|
|
}
|
|
return s.afterGenerationOutcome(ctx, generationOutcome{
|
|
Chat: committed,
|
|
Kind: runnerActionKindEnterRequiresAction,
|
|
WatchEventKind: codersdk.ChatWatchEventKindActionRequired,
|
|
})
|
|
}
|
|
|
|
// generationAttemptFence controls whether a terminal generation
|
|
// transition also verifies that the chat's GenerationAttempt counter
|
|
// matches an expected value.
|
|
type generationAttemptFence struct {
|
|
required bool
|
|
attempt int64
|
|
}
|
|
|
|
// generationAttemptNotRequired skips the generation attempt fence; only the
|
|
// running-task fence is verified.
|
|
var generationAttemptNotRequired = generationAttemptFence{}
|
|
|
|
// requireGenerationAttempt returns a fence that also verifies the chat's
|
|
// generation attempt matches the given value.
|
|
func requireGenerationAttempt(attempt int64) generationAttemptFence {
|
|
return generationAttemptFence{required: true, attempt: attempt}
|
|
}
|
|
|
|
// loadChatForGeneration loads the chat and verifies the running-task fence,
|
|
// additionally verifying the generation attempt fence when required.
|
|
func loadChatForGeneration(
|
|
ctx context.Context,
|
|
store database.Store,
|
|
input chatWorkerTaskStartInput,
|
|
fence generationAttemptFence,
|
|
) (database.Chat, error) {
|
|
chat, err := loadChatForTask(ctx, store, input, database.ChatStatusRunning, taskFenceOptions{requireHistory: true})
|
|
if err != nil {
|
|
return database.Chat{}, err
|
|
}
|
|
if fence.required && chat.GenerationAttempt != fence.attempt {
|
|
return database.Chat{}, errors.Join(errTaskExpectedExit, xerrors.Errorf("generation fence mismatch: %d != %d", chat.GenerationAttempt, fence.attempt))
|
|
}
|
|
return chat, nil
|
|
}
|
|
|
|
// recordGenerationFinishFailure records an error outcome on the debug turn
|
|
// when a terminal generation transition fails, so the debug run is not
|
|
// finalized as interrupted when work was actually done. It skips expected
|
|
// exits (fence lost, chat deleted) where another task owns the turn, and
|
|
// retryable errors where a task retry will record the real outcome.
|
|
func recordGenerationFinishFailure(turn *runnerDebugTurn, err error) {
|
|
if errors.Is(err, errTaskExpectedExit) || errors.Is(err, errTaskRetryable) {
|
|
return
|
|
}
|
|
turn.RecordOutcome(chatdebug.StatusError)
|
|
}
|
|
|
|
func (s *taskStarter) finishGenerationTurn(
|
|
ctx context.Context,
|
|
machine *chatstate.ChatMachine,
|
|
input chatWorkerTaskStartInput,
|
|
decision generationDecision,
|
|
fence generationAttemptFence,
|
|
) error {
|
|
var committed database.Chat
|
|
err := machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error {
|
|
if _, err := loadChatForGeneration(ctx, store, input, fence); err != nil {
|
|
return xerrors.Errorf("load chat for generation: %w", err)
|
|
}
|
|
finishResult, err := tx.FinishTurn(chatstate.FinishTurnInput{})
|
|
if err != nil {
|
|
return xerrors.Errorf("tx.FinishTurn: %w", err)
|
|
}
|
|
if finishResult.PromotedMessage != nil {
|
|
decision.promotedMessageID = finishResult.PromotedMessage.ID
|
|
}
|
|
committed = finishResult.Chat
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
err := normalizeTaskTransitionError(err, "finish generation turn")
|
|
recordGenerationFinishFailure(input.DebugTurn, err)
|
|
return err
|
|
}
|
|
input.DebugTurn.RecordOutcome(chatdebug.StatusCompleted)
|
|
watchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), postCommitWatchPublishTimeout)
|
|
defer cancel()
|
|
if err := s.publishWatchWithRetry(watchCtx, committed, codersdk.ChatWatchEventKindStatusChange); err != nil {
|
|
return xerrors.Errorf("publish watch and route: %w", err)
|
|
}
|
|
if err := s.afterGenerationOutcome(ctx, generationOutcome{
|
|
Chat: committed,
|
|
Kind: runnerActionKindFinishTurn,
|
|
WatchEventKind: codersdk.ChatWatchEventKindStatusChange,
|
|
PromotedMessageID: decision.promotedMessageID,
|
|
}); err != nil {
|
|
return xerrors.Errorf("after generation outcome: %w", err)
|
|
}
|
|
s.routeStateHint(ctx, stateUpdateFromChat(committed))
|
|
return nil
|
|
}
|
|
|
|
func (s *taskStarter) finishGenerationError(
|
|
ctx context.Context,
|
|
machine *chatstate.ChatMachine,
|
|
input chatWorkerTaskStartInput,
|
|
cause error,
|
|
fence generationAttemptFence,
|
|
) error {
|
|
classified := chaterror.Classify(cause)
|
|
// Log the unsanitized cause before persisting so administrators can
|
|
// diagnose the failure even when the classified user-facing message
|
|
// omits the underlying reason, and even if the persist below fails.
|
|
s.opts.Logger.Warn(ctx, "chat generation failed",
|
|
slog.F("chat_id", input.ChatID),
|
|
slog.F("worker_id", input.WorkerID),
|
|
slog.F("generation_attempt", input.GenerationAttempt),
|
|
slog.F("error_kind", classified.Kind),
|
|
slog.F("provider", classified.Provider),
|
|
slog.F("status_code", classified.StatusCode),
|
|
slog.F("retryable", classified.Retryable),
|
|
slog.Error(cause),
|
|
)
|
|
lastError, message := generationLastError(cause)
|
|
var committed database.Chat
|
|
err := machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error {
|
|
if _, err := loadChatForGeneration(ctx, store, input, fence); err != nil {
|
|
return xerrors.Errorf("load chat for generation: %w", err)
|
|
}
|
|
if _, err := tx.FinishError(chatstate.FinishErrorInput{LastError: lastError}); err != nil {
|
|
return xerrors.Errorf("tx.FinishError: %w", err)
|
|
}
|
|
chat, err := store.GetChatByID(ctx, input.ChatID)
|
|
if err != nil {
|
|
return xerrors.Errorf("load committed chat: %w", err)
|
|
}
|
|
committed = chat
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
err := normalizeTaskTransitionError(err, "finish generation error")
|
|
recordGenerationFinishFailure(input.DebugTurn, err)
|
|
return err
|
|
}
|
|
input.DebugTurn.RecordOutcome(chatdebug.StatusError)
|
|
if err := s.publishWatchAndRoute(ctx, committed, codersdk.ChatWatchEventKindStatusChange); err != nil {
|
|
return xerrors.Errorf("publish watch and route: %w", err)
|
|
}
|
|
return s.afterGenerationOutcome(ctx, generationOutcome{
|
|
Chat: committed,
|
|
Kind: runnerActionKindFinishError,
|
|
WatchEventKind: codersdk.ChatWatchEventKindStatusChange,
|
|
LastError: message,
|
|
})
|
|
}
|
|
|
|
func generationLastError(err error) (pqtype.NullRawMessage, string) {
|
|
if err == nil {
|
|
return pqtype.NullRawMessage{}, ""
|
|
}
|
|
classified := chaterror.Classify(err)
|
|
payload := chaterror.TerminalErrorPayload(classified)
|
|
if payload == nil {
|
|
payload = &codersdk.ChatError{Message: err.Error()}
|
|
}
|
|
encoded, marshalErr := json.Marshal(payload)
|
|
if marshalErr != nil {
|
|
return pqtype.NullRawMessage{}, payload.Message
|
|
}
|
|
return pqtype.NullRawMessage{RawMessage: encoded, Valid: true}, payload.Message
|
|
}
|
|
|
|
func (s *taskStarter) afterGenerationOutcome(ctx context.Context, outcome generationOutcome) error {
|
|
if err := s.server.afterGenerationOutcome(ctx, outcome); err != nil {
|
|
return taskRetryableError{err: xerrors.Errorf("generation post-outcome side effects: %w", err)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func stepDataFromPersisted(step chatloop.PersistedStep) stepData {
|
|
return stepData{
|
|
Content: step.Content,
|
|
Usage: step.Usage,
|
|
ContextLimit: step.ContextLimit,
|
|
Runtime: step.Runtime,
|
|
ToolCallCreatedAt: step.ToolCallCreatedAt,
|
|
ToolResultCreatedAt: step.ToolResultCreatedAt,
|
|
ReasoningStartedAt: step.ReasoningStartedAt,
|
|
ReasoningCompletedAt: step.ReasoningCompletedAt,
|
|
}
|
|
}
|