mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
## Context Follow-up fix from live UAT of the merged chat lifecycle hooks stack (#27430). Its companion UAT fix (#27655) has merged, so this targets `main` directly. ## Why? UAT measured a burst of 1,500 concurrent chat creations against a consumer with 1.2s latency. 255 were admitted and 1,245 got `502 hook_dispatch_failed (over_capacity)`, which is correct fail-closed behavior. The collateral wasn't: the same burst failed 24 `stop` dispatches, parking chats that had already been admitted and had already executed tools. One 256-slot semaphore served every event, so new-work admission could take every slot and kill turns in flight. Callers now classify each dispatch as admission or generation, and admission draws from a 192-slot gate held *before* the shared pool. At least 64 shared slots stay reachable only by dispatches for work a chat already admitted. The dispatcher is per `coderd` replica, so these limits are per replica, not deployment-wide, and the docs say so. **The caller classifies, not the event type.** Event type isn't a reliable proxy in either direction: a subagent spawn dispatches `user_prompt_submit` from inside a running turn, and the edit path dispatches `session_start` at admission time. `CapacityClassUnset` is rejected in `Dispatch`, so a new call site fails closed rather than silently inheriting a share. **Acquisition order is load-bearing.** Admission takes its own gate first. Taking a shared slot first would let admissions queued on the gate occupy the very capacity the reserve protects. `acquireCapacity` is the only path that takes either pool, so the order can't be bypassed. ## What this does not guarantee Nothing bounds how many turns generate concurrently, so the 192/64 split is a judgement call, not a derived ceiling. This stops an *admission* burst from consuming every slot; it does not make the remainder sufficient. A large enough generation load can still exhaust the reserve and error a running chat. The docs say so explicitly rather than promising a guarantee the code doesn't deliver. Generation can now take all 256 slots, so generation traffic starves admission harder than before. That's the intended priority: rejecting a new prompt is recoverable, ending a turn that already ran tools is not. ## Testing Red-green proved both new tests. Removing the release-on-failure path fails `RefusedSharedAcquireReleasesAdmission` deterministically; removing the expired-deadline check fails `ExpiredDeadlineRefusesFreeSlot` in 18/30 runs. That deadline check fixes a real race found in review. `acquire` previously shared one `time.Timer` across both acquires. Because `select` picks a ready case at random, an admission dispatch could take a slot after its capacity deadline had passed. Measured over 300 trials: 135 late acquisitions, worst overshoot 2.1ms. `acquire` now takes an absolute deadline and refuses an expired one before selecting, which measures 0/300. Go: `coderd/x/agenthooks/...` and `coderd/x/chatd/...`, plus `-race -count=3` on the dispatcher. > Mux opened this PR on Mike's behalf.
1501 lines
56 KiB
Go
1501 lines
56 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/agenthooks/dispatch"
|
|
"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/chathooks"
|
|
"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"
|
|
"github.com/coder/coder/v2/codersdk/x/agenthooks"
|
|
)
|
|
|
|
// 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 {
|
|
// Override, when non-nil, is the compaction model override resolved at
|
|
// prepare time. Its model client is built in the compact action path,
|
|
// so construction failures cannot fail turns that never compact.
|
|
Override *resolvedCompactionOverride
|
|
// ChatModelConfig is the chat model's config, used to detect provider
|
|
// changes when sanitizing the compaction prompt.
|
|
ChatModelConfig database.ChatModelConfig
|
|
|
|
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
|
|
finishReason generationFinishReason
|
|
promotedMessageID int64
|
|
// forced marks a compact action triggered by a manual
|
|
// compaction request rather than the usage threshold.
|
|
forced bool
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
}
|
|
return generationDecision{kind: generationActionExecuteLocalTools, localToolCalls: localCalls}, nil
|
|
}
|
|
if len(dynamicCalls) > 0 {
|
|
return generationDecision{kind: generationActionEnterRequiresAction}, nil
|
|
}
|
|
|
|
// A manual compaction request wins over every non-tool decision:
|
|
// idle chats would otherwise finish the turn via the
|
|
// history-complete check before ever compacting. The request is
|
|
// ignored when nothing after the latest boundary is compactable
|
|
// (for example the history was edited between request and
|
|
// execution); the stale marker is then cleared by the terminal
|
|
// transition of this turn.
|
|
if input.chat.CompactionRequestedAt.Valid {
|
|
boundary := latestCompactionBoundaryIndex(input.messages)
|
|
if _, ok := firstUncompressedAssistantAfter(input.messages, boundary); ok {
|
|
return generationDecision{kind: generationActionCompact, forced: true}, 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
|
|
}
|
|
|
|
// generationCompactionContextLimit returns the context limit the compaction
|
|
// trigger was evaluated against at prepare time (the stricter of the chat and
|
|
// override models' limits). The still-over-limit check must compare against
|
|
// the same limit, otherwise a stricter override loops through repeated
|
|
// compactions instead of surfacing errCompactionStillOverLimit.
|
|
func generationCompactionContextLimit(compaction *generationCompaction) int64 {
|
|
if compaction == nil {
|
|
return 0
|
|
}
|
|
return compaction.Options.ContextLimit
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// exclusiveBatchRejected reports whether the exclusive-tool policy will
|
|
// reject the whole batch, which mirrors the condition chatloop applies
|
|
// when it decides that nothing in the batch may execute. Callers must ask
|
|
// before filtering the batch, because dropping calls from it can leave the
|
|
// exclusive call alone and admissible.
|
|
func exclusiveBatchRejected(toolCalls []fantasy.ToolCallContent, exclusiveToolNames map[string]bool) bool {
|
|
return len(toolCalls) > 1 && hasExclusiveToolCall(toolCalls, exclusiveToolNames)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
type sessionStartResult struct {
|
|
Chat database.Chat
|
|
}
|
|
|
|
func applySessionStartResponse(
|
|
ctx context.Context,
|
|
machine *chatstate.ChatMachine,
|
|
input chatWorkerTaskStartInput,
|
|
chat database.Chat,
|
|
result *chathooks.Result,
|
|
) (sessionStartResult, error) {
|
|
if result.GetModelContext() == "" && result.GetUserMessage() == "" {
|
|
return sessionStartResult{Chat: chat}, nil
|
|
}
|
|
|
|
eventMessages, err := chathooks.EventMessages(result, chat.LastModelConfigID)
|
|
if err != nil {
|
|
return sessionStartResult{}, err
|
|
}
|
|
|
|
var applied sessionStartResult
|
|
err = machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error {
|
|
if _, err := loadChatForGeneration(ctx, store, input, generationAttemptNotRequired); err != nil {
|
|
return xerrors.Errorf("load chat for session_start response: %w", err)
|
|
}
|
|
if len(eventMessages) > 0 {
|
|
if _, err := tx.CommitStep(chatstate.CommitStepInput{Messages: eventMessages}); err != nil {
|
|
return xerrors.Errorf("insert session_start response messages: %w", err)
|
|
}
|
|
}
|
|
applied.Chat, err = store.GetChatByID(ctx, input.ChatID)
|
|
if err != nil {
|
|
return xerrors.Errorf("reload chat after session_start response: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return sessionStartResult{}, normalizeTaskTransitionError(err, "apply session_start response")
|
|
}
|
|
return applied, nil
|
|
}
|
|
|
|
func (s *taskStarter) startGenerationSession(
|
|
ctx context.Context,
|
|
machine *chatstate.ChatMachine,
|
|
input chatWorkerTaskStartInput,
|
|
chat database.Chat,
|
|
messages []database.ChatMessage,
|
|
) (result sessionStartResult, dispatched bool, err error) {
|
|
dispatched, complete, err := input.SessionStart.claim(ctx)
|
|
if err != nil {
|
|
return sessionStartResult{}, false, errors.Join(errTaskExpectedExit, xerrors.Errorf("claim session_start: %w", err))
|
|
}
|
|
if !dispatched {
|
|
return sessionStartResult{Chat: chat}, false, nil
|
|
}
|
|
|
|
completed := false
|
|
// Re-arm the claim until its response is applied so a replacement task
|
|
// can replay session_start effects.
|
|
defer func() { complete(completed) }()
|
|
response, err := s.server.hooks.Trigger(ctx, chathooks.ChatFor(chat, input.hookTurnID()), chathooks.Message{Source: chathooks.SessionStartSource(messages)}, agenthooks.EventSessionStart, dispatch.CapacityClassGeneration)
|
|
if err != nil {
|
|
return sessionStartResult{}, true, chathooks.GenerationDispatchError(agenthooks.EventSessionStart, err)
|
|
}
|
|
result, err = applySessionStartResponse(ctx, machine, input, chat, response)
|
|
if err != nil {
|
|
return sessionStartResult{}, true, err
|
|
}
|
|
completed = true
|
|
return result, true, nil
|
|
}
|
|
|
|
func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskStartInput) error {
|
|
if input.StopNudges == nil {
|
|
input.StopNudges = &stopNudgeTracker{}
|
|
}
|
|
if input.TurnID == uuid.Nil {
|
|
input.TurnID = uuid.New()
|
|
}
|
|
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)
|
|
}
|
|
if s.server.hooks.Enabled() {
|
|
result, dispatched, err := s.startGenerationSession(ctx, machine, input, chat, messages)
|
|
if err != nil {
|
|
if errors.Is(err, errTaskExpectedExit) {
|
|
return err
|
|
}
|
|
return s.finishGenerationError(ctx, machine, input, err, generationAttemptNotRequired)
|
|
}
|
|
if dispatched {
|
|
input.HistoryVersion = result.Chat.HistoryVersion
|
|
continue
|
|
}
|
|
}
|
|
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
|
|
var decision generationDecision
|
|
if input.StopNudges.consume(stopNudgeKey(prepared.Messages)) {
|
|
decision = generationDecision{kind: generationActionGenerateAssistant}
|
|
} else {
|
|
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: generationCompactionContextLimit(prepared.Compaction),
|
|
})
|
|
})
|
|
}
|
|
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 {
|
|
metricProvider, metricModel := compactionMetricIdentity(prepared.Compaction)
|
|
s.server.metrics.RecordCompaction(
|
|
metricProvider,
|
|
metricModel,
|
|
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, compactionSourceForDecision(decision))
|
|
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))
|
|
}
|
|
preflight, err := s.admitStepToolCalls(ctx, input, prepared, outcome.Step.Content)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
outcome.Step.Content = chathooks.ApplyAdmittedToolCalls(outcome.Step.Content, preflight)
|
|
messages, err := buildCommitStepMessages(buildCommitStepMessagesInput{
|
|
modelConfigID: prepared.ModelConfigID,
|
|
modelCallConfig: prepared.ModelConfig,
|
|
step: stepDataFromPersisted(outcome.Step),
|
|
toolNameToConfigID: prepared.ToolNameToConfigID,
|
|
logger: s.opts.Logger,
|
|
contentVersion: chatprompt.CurrentContentVersion,
|
|
hookRewrittenToolCalls: preflight.Overrides,
|
|
})
|
|
if err != nil {
|
|
return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number))
|
|
}
|
|
messages, err = appendHookResultMessages(messages, preflight.Results, prepared.ModelConfigID)
|
|
if err != nil {
|
|
return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number))
|
|
}
|
|
return s.commitGenerationStep(ctx, machine, input, attempt.number, generationActionGenerateAssistant, messages, generationCommitHooks{})
|
|
}
|
|
|
|
func (s *taskStarter) admitStepToolCalls(
|
|
ctx context.Context,
|
|
input chatWorkerTaskStartInput,
|
|
prepared generationPrepared,
|
|
content []fantasy.Content,
|
|
) (chathooks.PreToolUseExecutionResult, error) {
|
|
if !s.server.hooks.Enabled() {
|
|
return chathooks.PreToolUseExecutionResult{}, nil
|
|
}
|
|
toolCalls := chathooks.PendingToolCalls(content)
|
|
if len(toolCalls) == 0 || exclusiveBatchRejected(toolCalls, prepared.ExclusiveToolNames) {
|
|
return chathooks.PreToolUseExecutionResult{}, nil
|
|
}
|
|
// Check the full batch first: a call removed below still occupies its ID
|
|
// in the step, so filtering before this would hide the collision.
|
|
if err := chathooks.RejectDuplicateToolUseIDs(toolCalls); err != nil {
|
|
return chathooks.PreToolUseExecutionResult{}, chathooks.GenerationDispatchError(agenthooks.EventPreToolUse, err)
|
|
}
|
|
unambiguous, ambiguous := partitionAmbiguousToolCalls(prepared, toolCalls)
|
|
preflight, err := s.server.hooks.PreflightPendingToolCalls(ctx, chathooks.ChatFor(prepared.Chat, input.hookTurnID()), unambiguous)
|
|
if err != nil {
|
|
return chathooks.PreToolUseExecutionResult{}, chathooks.GenerationDispatchError(agenthooks.EventPreToolUse, err)
|
|
}
|
|
if err := validateOverriddenToolInputs(prepared, preflight); err != nil {
|
|
return chathooks.PreToolUseExecutionResult{}, chathooks.GenerationDispatchError(agenthooks.EventPreToolUse, err)
|
|
}
|
|
preflight.Denied = append(preflight.Denied, ambiguous...)
|
|
return preflight, nil
|
|
}
|
|
|
|
func (s *taskStarter) executeLocalTools(
|
|
ctx context.Context,
|
|
machine *chatstate.ChatMachine,
|
|
input chatWorkerTaskStartInput,
|
|
prepared generationPrepared,
|
|
decision generationDecision,
|
|
) error {
|
|
allowed := decision.localToolCalls
|
|
var denied []fantasy.ToolResultContent
|
|
if !exclusiveBatchRejected(decision.localToolCalls, prepared.ExclusiveToolNames) {
|
|
allowed, denied = partitionAmbiguousToolCalls(prepared, decision.localToolCalls)
|
|
}
|
|
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()
|
|
}
|
|
var outcome chatloop.ToolExecutionOutcome
|
|
var spawnDispatchErr error
|
|
if len(allowed) > 0 {
|
|
outcome, err = chatloop.ExecuteLocalTools(ctx, chatloop.ExecuteLocalToolsOptions{
|
|
Tools: prepared.Tools,
|
|
ActiveTools: prepared.ActiveTools,
|
|
ProviderTools: prepared.ProviderTools,
|
|
ToolCalls: allowed,
|
|
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)
|
|
}
|
|
// Subagent spawn admission dispatches user_prompt_submit inside
|
|
// the tool run; its failure surfaces as a tool result error. The
|
|
// step still commits so a sibling tool that already ran keeps its
|
|
// result and is not re-executed, and the turn fails afterwards.
|
|
if hookErr := chathooks.DispatchFailureFromResults(outcome.Step.Content); hookErr != nil {
|
|
spawnDispatchErr = chathooks.GenerationDispatchError(agenthooks.EventUserPromptSubmit, hookErr)
|
|
}
|
|
}
|
|
postResults, postDispatchErr := s.server.hooks.PostToolUseResults(ctx, chathooks.ChatFor(prepared.Chat, input.hookTurnID()), outcome.Step.Content)
|
|
for _, result := range denied {
|
|
outcome.Step.Content = append(outcome.Step.Content, result)
|
|
}
|
|
chathooks.RestoreToolCallOrder(outcome.Step.Content, decision.localToolCalls)
|
|
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))
|
|
}
|
|
messages, err = appendHookResultMessages(messages, postResults, prepared.ModelConfigID)
|
|
if err != nil {
|
|
return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number))
|
|
}
|
|
var postCommitErr error
|
|
switch {
|
|
case spawnDispatchErr != nil:
|
|
// Spawn admission is the causal root: post_tool_use only ran
|
|
// because the batch reached execution at all.
|
|
postCommitErr = spawnDispatchErr
|
|
if postDispatchErr != nil {
|
|
s.opts.Logger.Warn(ctx, "post_tool_use hook dispatch failed alongside spawn admission",
|
|
slog.F("chat_id", input.ChatID),
|
|
slog.F("worker_id", input.WorkerID),
|
|
slog.Error(postDispatchErr),
|
|
)
|
|
}
|
|
case postDispatchErr != nil:
|
|
postCommitErr = chathooks.GenerationDispatchError(agenthooks.EventPostToolUse, postDispatchErr)
|
|
}
|
|
return s.commitGenerationStep(ctx, machine, input, attempt.number, generationActionExecuteLocalTools, messages, generationCommitHooks{
|
|
PostCommitError: postCommitErr,
|
|
})
|
|
}
|
|
|
|
// compactionSourceForDecision maps a compact decision to the
|
|
// compaction source recorded in the summary messages. Manual
|
|
// requests also force the compaction past the usage-threshold gates.
|
|
func compactionSourceForDecision(decision generationDecision) chatloop.CompactionSource {
|
|
if decision.forced {
|
|
return chatloop.CompactionSourceManual
|
|
}
|
|
return chatloop.CompactionSourceAutomatic
|
|
}
|
|
|
|
func (s *taskStarter) generateCompaction(
|
|
ctx context.Context,
|
|
machine *chatstate.ChatMachine,
|
|
input chatWorkerTaskStartInput,
|
|
prepared generationPrepared,
|
|
source chatloop.CompactionSource,
|
|
) 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
|
|
metricProvider, metricModel := compactionMetricIdentity(prepared.Compaction)
|
|
if override := prepared.Compaction.Override; override != nil {
|
|
overrideModel, err := s.server.buildCompactionOverrideModel(ctx, prepared.Chat, override.Config, prepared.ModelBuildOptions)
|
|
if err != nil {
|
|
return xerrors.Errorf("build compaction model override: %w", err)
|
|
}
|
|
logger := s.server.logger.With(
|
|
slog.F("chat_id", prepared.Chat.ID),
|
|
slog.F("owner_id", prepared.Chat.OwnerID),
|
|
)
|
|
compactionOpts.Model = overrideModel.model
|
|
compactionOpts.ResolvedProvider = overrideModel.resolvedProvider
|
|
compactionOpts.ResolvedModel = overrideModel.resolvedModel
|
|
compactionOpts.ModelConfigID = overrideModel.modelConfig.ID
|
|
compactionOpts.ProviderOptions = overrideModel.providerOptions
|
|
compactionOpts.Messages = sanitizeCompactionPrompt(
|
|
ctx,
|
|
logger,
|
|
compactionOpts.Messages,
|
|
overrideModel.model,
|
|
prepared.Compaction.ChatModelConfig,
|
|
overrideModel.modelConfig,
|
|
)
|
|
}
|
|
preResult, err := s.server.hooks.Trigger(ctx, chathooks.ChatFor(prepared.Chat, input.hookTurnID()), chathooks.Message{}, agenthooks.EventPreCompact, dispatch.CapacityClassGeneration)
|
|
if err != nil {
|
|
return chathooks.GenerationDispatchError(agenthooks.EventPreCompact, err)
|
|
}
|
|
compactionOpts.SummaryHint = preResult.GetModelContext()
|
|
compactionOpts.PublishMessagePart = attempt.publish
|
|
compactionOpts.Source = source
|
|
compactionOpts.Force = source == chatloop.CompactionSourceManual
|
|
// Attach the turn debug run so the compaction call records a child
|
|
// debug run; without it startCompactionDebugRun finds no parent and
|
|
// skips debug instrumentation entirely.
|
|
runCtx := input.DebugTurn.Ensure(ctx, prepared.Chat, prepared.Debug)
|
|
outcome, err := chatloop.GenerateCompaction(runCtx, compactionOpts)
|
|
if err != nil {
|
|
s.server.metrics.RecordCompaction(metricProvider, metricModel, 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(metricProvider, metricModel, false, err)
|
|
return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number))
|
|
}
|
|
messages, err := buildCompactionMessages(buildCompactionMessagesInput{
|
|
modelConfigID: prepared.ModelConfigID,
|
|
toolCallID: compactionOpts.ToolCallID,
|
|
toolName: compactionOpts.ToolName,
|
|
compaction: compactionOutcome(outcome),
|
|
contentVersion: chatprompt.CurrentContentVersion,
|
|
})
|
|
if err != nil {
|
|
s.server.metrics.RecordCompaction(metricProvider, metricModel, false, err)
|
|
return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number))
|
|
}
|
|
// The summary hint already consumed the pre_compact model context.
|
|
persistedPreResult := &chathooks.Result{UserMessage: preResult.GetUserMessage()}
|
|
commitMessages, err := applyHookResultMessages(stepMessagesForCommit{
|
|
Messages: messages.Messages,
|
|
VisibleIndexes: visibleMessageIndexes(messages.Messages),
|
|
ConsumeCompactionRequest: true,
|
|
}, []*chathooks.Result{persistedPreResult}, prepared.ModelConfigID)
|
|
if err != nil {
|
|
s.server.metrics.RecordCompaction(metricProvider, metricModel, false, err)
|
|
return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number))
|
|
}
|
|
// Hook effects and fail-closed errors must commit atomically with
|
|
// compaction; a separate commit races the runner and can be dropped
|
|
// on crash.
|
|
postResult, postDispatchErr := s.server.hooks.Trigger(ctx, chathooks.ChatFor(prepared.Chat, input.hookTurnID()), chathooks.Message{}, agenthooks.EventPostCompact, dispatch.CapacityClassGeneration)
|
|
var postCommitErr error
|
|
if postDispatchErr != nil {
|
|
postCommitErr = chathooks.GenerationDispatchError(agenthooks.EventPostCompact, postDispatchErr)
|
|
} else {
|
|
commitMessages, err = appendHookResultMessages(commitMessages, []*chathooks.Result{postResult}, prepared.ModelConfigID)
|
|
if err != nil {
|
|
s.server.metrics.RecordCompaction(metricProvider, metricModel, false, err)
|
|
return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number))
|
|
}
|
|
}
|
|
err = s.commitGenerationStep(ctx, machine, input, attempt.number, generationActionCompact, commitMessages, generationCommitHooks{
|
|
PostCommitError: postCommitErr,
|
|
})
|
|
s.server.metrics.RecordCompaction(metricProvider, metricModel, err == nil, err)
|
|
if err != nil {
|
|
return xerrors.Errorf("commit compaction step: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// compactionMetricIdentity returns the provider/model labels for compaction
|
|
// metrics. Override labels come from prepare-time resolution so events
|
|
// recorded before the override client is built (still-over-limit) match
|
|
// the compact action's own events.
|
|
func compactionMetricIdentity(compaction *generationCompaction) (provider, model string) {
|
|
if compaction.Override != nil {
|
|
return compaction.Override.ResolvedProvider, compaction.Override.ResolvedModel
|
|
}
|
|
return compactionProvider(compaction.Options), compactionModel(compaction.Options)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
type generationCommitHooks struct {
|
|
PostCommitError error
|
|
}
|
|
|
|
func (s *taskStarter) commitGenerationStep(
|
|
ctx context.Context,
|
|
machine *chatstate.ChatMachine,
|
|
input chatWorkerTaskStartInput,
|
|
attempt int64,
|
|
kind generationActionKind,
|
|
messages stepMessagesForCommit,
|
|
commitHooks generationCommitHooks,
|
|
) error {
|
|
if len(messages.Messages) == 0 {
|
|
if commitHooks.PostCommitError != nil {
|
|
return s.finishGenerationError(ctx, machine, input, commitHooks.PostCommitError, requireGenerationAttempt(attempt))
|
|
}
|
|
return s.finishGenerationTurn(ctx, machine, input, generationDecision{kind: generationActionFinishTurn, finishReason: generationFinishReasonComplete}, requireGenerationAttempt(attempt))
|
|
}
|
|
failClosed := commitHooks.PostCommitError != nil
|
|
var postCommitLastError pqtype.NullRawMessage
|
|
var postCommitMessage string
|
|
if commitHooks.PostCommitError != nil {
|
|
classified := chaterror.Classify(commitHooks.PostCommitError)
|
|
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(commitHooks.PostCommitError),
|
|
)
|
|
postCommitLastError, postCommitMessage = generationLastError(commitHooks.PostCommitError)
|
|
}
|
|
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,
|
|
ConsumeCompactionRequest: messages.ConsumeCompactionRequest,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("tx.CommitStep: %w", err)
|
|
}
|
|
inserted := commitResult.InsertedMessages
|
|
// The fail-closed hook error must commit atomically with the
|
|
// step; a separate commit races the runner and can be dropped
|
|
// on crash.
|
|
if failClosed {
|
|
if _, err := tx.FinishError(chatstate.FinishErrorInput{LastError: postCommitLastError}); err != nil {
|
|
return xerrors.Errorf("tx.FinishError: %w", err)
|
|
}
|
|
}
|
|
insertedMessages = make([]runnerActionMessage, 0, len(inserted))
|
|
for _, msg := range inserted {
|
|
insertedMessages = append(insertedMessages, runnerActionMessage{ID: msg.ID, Role: codersdk.ChatMessageRole(msg.Role)})
|
|
}
|
|
loadedChat, err := store.GetChatByID(ctx, input.ChatID)
|
|
if err != nil {
|
|
return xerrors.Errorf("load committed chat: %w", err)
|
|
}
|
|
committed = loadedChat
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return normalizeTaskTransitionError(err, "commit generation step")
|
|
}
|
|
if failClosed {
|
|
input.DebugTurn.RecordOutcome(chatdebug.StatusError)
|
|
postCommitCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), postCommitWatchPublishTimeout)
|
|
defer cancel()
|
|
if err := s.publishWatchAndRoute(postCommitCtx, committed, codersdk.ChatWatchEventKindStatusChange); err != nil {
|
|
return xerrors.Errorf("publish watch and route: %w", err)
|
|
}
|
|
return s.afterGenerationOutcome(postCommitCtx, generationOutcome{
|
|
Chat: committed,
|
|
Kind: runnerActionKindFinishError,
|
|
WatchEventKind: codersdk.ChatWatchEventKindStatusChange,
|
|
LastError: postCommitMessage,
|
|
})
|
|
}
|
|
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) completeGenerationTurn(
|
|
ctx context.Context,
|
|
input chatWorkerTaskStartInput,
|
|
committed database.Chat,
|
|
promotedMessageID int64,
|
|
) error {
|
|
input.StopNudges.reset()
|
|
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: promotedMessageID,
|
|
}); err != nil {
|
|
return xerrors.Errorf("after generation outcome: %w", err)
|
|
}
|
|
s.routeStateHint(ctx, stateUpdateFromChat(committed))
|
|
return nil
|
|
}
|
|
|
|
func (s *taskStarter) finishGenerationTurnWithoutHook(
|
|
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
|
|
}
|
|
return s.completeGenerationTurn(ctx, input, committed, decision.promotedMessageID)
|
|
}
|
|
|
|
func (s *taskStarter) finishGenerationTurn(
|
|
ctx context.Context,
|
|
machine *chatstate.ChatMachine,
|
|
input chatWorkerTaskStartInput,
|
|
decision generationDecision,
|
|
fence generationAttemptFence,
|
|
) error {
|
|
if !s.server.hooks.Enabled() {
|
|
return s.finishGenerationTurnWithoutHook(ctx, machine, input, decision, fence)
|
|
}
|
|
var chat database.Chat
|
|
var messages []database.ChatMessage
|
|
err := machine.ReadLock(ctx, func(store database.Store) error {
|
|
loadedChat, err := loadChatForGeneration(ctx, store, input, fence)
|
|
if err != nil {
|
|
return xerrors.Errorf("load chat for stop hook: %w", err)
|
|
}
|
|
loadedMessages, err := store.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
|
|
ChatID: input.ChatID,
|
|
AfterID: 0,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("load messages for stop hook: %w", err)
|
|
}
|
|
chat = loadedChat
|
|
messages = loadedMessages
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return normalizeTaskTransitionError(err, "load stop hook state")
|
|
}
|
|
response, err := s.server.hooks.Trigger(ctx, chathooks.ChatFor(chat, input.hookTurnID()), chathooks.Message{}, agenthooks.EventStop, dispatch.CapacityClassGeneration)
|
|
if err != nil {
|
|
return s.finishGenerationError(ctx, machine, input, chathooks.GenerationDispatchError(agenthooks.EventStop, err), fence)
|
|
}
|
|
stopMessages, err := chathooks.EventMessages(response, chat.LastModelConfigID)
|
|
if err != nil {
|
|
return s.finishGenerationError(ctx, machine, input, err, fence)
|
|
}
|
|
nudgeKey := stopNudgeKey(messages)
|
|
// Prompt conversion drops whitespace-only text parts, so a blank
|
|
// model context would buy a continuation that nudges nothing.
|
|
continueTurn := strings.TrimSpace(response.GetModelContext()) != "" && input.StopNudges.claim(nudgeKey)
|
|
|
|
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 len(stopMessages) > 0 {
|
|
if _, err := tx.CommitStep(chatstate.CommitStepInput{Messages: stopMessages}); err != nil {
|
|
return xerrors.Errorf("commit stop hook messages: %w", err)
|
|
}
|
|
}
|
|
if !continueTurn {
|
|
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
|
|
}
|
|
loadedChat, err := store.GetChatByID(ctx, input.ChatID)
|
|
if err != nil {
|
|
return xerrors.Errorf("load committed chat: %w", err)
|
|
}
|
|
committed = loadedChat
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
if continueTurn {
|
|
input.StopNudges.cancel(nudgeKey)
|
|
}
|
|
err := normalizeTaskTransitionError(err, "finish generation turn")
|
|
recordGenerationFinishFailure(input.DebugTurn, err)
|
|
return err
|
|
}
|
|
if continueTurn {
|
|
s.routeStateHint(ctx, stateUpdateFromChat(committed))
|
|
return s.afterGenerationOutcome(ctx, generationOutcome{
|
|
Chat: committed,
|
|
Kind: runnerActionKind(generationActionGenerateAssistant),
|
|
})
|
|
}
|
|
return s.completeGenerationTurn(ctx, input, committed, decision.promotedMessageID)
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|