Files
coder/coderd/x/chatd/generation_preparer.go
T

783 lines
25 KiB
Go

package chatd
import (
"context"
"encoding/json"
"slices"
"strings"
"sync"
"charm.land/fantasy"
"github.com/google/uuid"
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/x/chatd/chatadvisor"
"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/chatprovider"
"github.com/coder/coder/v2/coderd/x/chatd/chatsanitize"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/coderd/x/chatd/mcpclient"
skillspkg "github.com/coder/coder/v2/coderd/x/skills"
"github.com/coder/coder/v2/codersdk"
)
func (server *Server) prepareGeneration(
ctx context.Context,
input generationPrepareInput,
) (generationPrepared, error) {
chat := input.Chat
logger := server.logger.With(
slog.F("chat_id", chat.ID),
slog.F("owner_id", chat.OwnerID),
)
var (
model fantasy.LanguageModel
modelConfig database.ChatModelConfig
modelRoute aiGatewayModelRoute
modelOpts modelBuildOptions
callConfig codersdk.ChatModelCallConfig
promptRows []database.ChatMessage
mcpConfigs []database.MCPServerConfig
mcpTokens []database.MCPServerUserToken
debugEnabled bool
resolvedProvider string
debugModel string
)
var g errgroup.Group
g.Go(func() error {
var err error
promptRows, err = server.db.GetChatMessagesForPromptByChatID(ctx, chat.ID)
if err != nil {
return xerrors.Errorf("get chat messages for prompt: %w", err)
}
return nil
})
if len(chat.MCPServerIDs) > 0 {
g.Go(func() error {
var err error
mcpConfigs, err = server.db.GetMCPServerConfigsByIDs(ctx, chat.MCPServerIDs)
if err != nil {
logger.Warn(ctx, "failed to load MCP server configs", slog.Error(err))
}
return nil
})
g.Go(func() error {
var err error
mcpTokens, err = server.db.GetMCPServerUserTokensByUserID(ctx, chat.OwnerID)
if err != nil {
logger.Warn(ctx, "failed to load MCP user tokens", slog.Error(err))
}
return nil
})
}
if err := g.Wait(); err != nil {
return generationPrepared{}, err
}
modelOpts = modelBuildOptionsFromMessages(promptRows)
ctx = withActiveTurnAPIKeyID(ctx, modelOpts)
var err error
model, modelConfig, modelRoute, debugEnabled, resolvedProvider, debugModel, err = server.resolveChatModel(ctx, chat, modelOpts)
if err != nil {
return generationPrepared{}, err
}
if len(modelConfig.Options) > 0 {
if err := json.Unmarshal(modelConfig.Options, &callConfig); err != nil {
return generationPrepared{}, xerrors.Errorf("parse model call config: %w", err)
}
}
if callConfig.MaxOutputTokens == nil {
maxOutputTokens := int64(32_000)
callConfig.MaxOutputTokens = &maxOutputTokens
}
currentPlanMode := chat.PlanMode
isPlanModeTurn := currentPlanMode.Valid && currentPlanMode.ChatPlanMode == database.ChatPlanModePlan
isExploreSubagent := isExploreSubagentMode(chat.Mode)
isRootChat := !chat.ParentChatID.Valid
mcpConnectConfigs, approvedPlanMCPConfigIDs := filterExternalMCPConfigsForTurn(
mcpConfigs,
currentPlanMode,
chat.ParentChatID,
)
if isExploreSubagent && isRootChat {
mcpConnectConfigs = nil
approvedPlanMCPConfigIDs = map[uuid.UUID]struct{}{}
}
planModeInstructions := server.loadPlanModeInstructions(ctx, currentPlanMode, logger)
advisorCfg := server.loadAdvisorConfig(ctx, logger)
// Force Enabled from the experiment; the stored DB value is ignored.
advisorCfg.Enabled = server.experiments.Enabled(codersdk.ExperimentChatAdvisor)
var advisorRuntime *chatadvisor.Runtime
if advisorCfg.Enabled && isRootChat && !isPlanModeTurn && !isExploreSubagent {
var advisorErr error
advisorRuntime, advisorErr = server.newAdvisorRuntime(
ctx,
chat,
advisorCfg,
model,
callConfig,
modelOpts,
logger,
)
if advisorErr != nil {
return generationPrepared{}, advisorErr
}
}
var advisorPromptSnapshot []fantasy.Message
setAdvisorPromptSnapshot := func(msgs []fantasy.Message) {
if advisorRuntime == nil {
return
}
advisorPromptSnapshot = slices.Clone(msgs)
}
currentChat := chat
loadChatSnapshot := func(loadCtx context.Context, chatID uuid.UUID) (database.Chat, error) {
return server.db.GetChatByID(loadCtx, chatID)
}
var chatStateMu sync.Mutex
var workspaceMu sync.Mutex
workspaceCtx := turnWorkspaceContext{
server: server,
chatStateMu: &chatStateMu,
currentChat: &currentChat,
loadChatSnapshot: loadChatSnapshot,
}
cleanup := func() {
workspaceCtx.close()
}
planPathFn := func(ctx context.Context) (string, string, error) {
conn, err := workspaceCtx.getWorkspaceConn(ctx)
if err != nil {
return "", "", err
}
home, err := chattool.ResolveWorkspaceHome(ctx, conn)
if err != nil {
return "", "", err
}
return chattool.PlanPathForChat(home, chat.ID), home, nil
}
resolvePlanPathForTools := func(ctx context.Context) (string, string, error) {
planCtx, cancel := context.WithTimeout(ctx, planPathLookupTimeout)
defer cancel()
return planPathFn(planCtx)
}
resolvePlanPathBlock := func(resolveCtx context.Context) string {
if chat.ParentChatID.Valid {
return ""
}
planCtx, cancel := context.WithTimeout(resolveCtx, planPathLookupTimeout)
defer cancel()
if _, _, err := workspaceCtx.workspaceAgentIDForConn(planCtx); err != nil {
logger.Debug(resolveCtx, "plan path instruction: agent not reachable",
slog.Error(err),
slog.F("chat_id", chat.ID),
)
return ""
}
planPath, home, err := planPathFn(planCtx)
if err != nil {
logger.Debug(resolveCtx, "plan path instruction: failed to resolve plan path",
slog.Error(err),
slog.F("chat_id", chat.ID),
)
return ""
}
return formatPlanPathBlock(planPath, home)
}
var (
prompt []fantasy.Message
instruction string
mcpTools []fantasy.AgentTool
mcpCleanup func()
workspaceMCPTools []fantasy.AgentTool
workspaceSkills []chattool.SkillMeta
personalSkills []skillspkg.Skill
resolvedUserPrompt string
planPathBlock string
)
// Drop provider-executed tool history produced by a different provider
// before building the prompt. A provider that shares another's wire format
// (e.g. Bedrock and Anthropic) can still reject the other's
// provider-executed blocks, so a mid-chat provider switch must not replay
// them.
promptRows = server.sanitizeForeignProviderExecutedToolRows(ctx, logger, promptRows, modelConfig.ID)
if chat.WorkspaceID.Valid {
// Resolve the workspace agent so the chat row's AgentID and
// BuildID bindings are up to date before the chatworker
// decision helper inspects them. ensureWorkspaceAgent does a
// DB lookup and lazily calls persistBuildAgentBinding when
// the bound agent has changed, so this is a cheap metadata
// refresh, not a workspace dial. It must not insert chat
// history; only metadata is mutated here.
agent, _ := workspaceCtx.getWorkspaceAgent(ctx)
// API-created chats bind their agent lazily here, after
// hydrateChatContextOnCreate ran with no agent. Pin the chat to the
// bound agent's pushed snapshot now if it is still unpinned, so the
// first turn reads workspace context instead of waiting for the
// agent's next push. Idempotent and snapshot-gated; runs before the
// pinned context is read below.
server.ensureChatContextPinnedOnFirstTurn(ctx, workspaceCtx.currentChatSnapshot())
var resolveErr error
instruction, workspaceSkills, resolveErr = server.resolveTurnWorkspaceContext(ctx, chat, agent)
if resolveErr != nil {
cleanup()
return generationPrepared{}, resolveErr
}
}
var g2 errgroup.Group
g2.Go(func() error {
var err error
// Key the file-part acceptance on model.Provider() (the fantasy
// transport identity), not the configured provider, because
// aibridge routing rewrites the provider (e.g. Bedrock to the
// Anthropic transport). The conversion that actually drops or
// accepts a file part is the one for model.Provider().
acceptsFilePart := func(mediaType string) bool {
return chatprovider.AcceptsFilePartMediaType(model.Provider(), model.Model(), mediaType)
}
providerType := string(modelRoute.Provider.Type)
prompt, err = chatprompt.ConvertMessagesWithFiles(ctx, promptRows, server.chatFileResolver(providerType), logger, acceptsFilePart)
if err != nil {
return xerrors.Errorf("build chat prompt: %w", err)
}
return nil
})
g2.Go(func() error {
personalSkills = server.fetchPersonalSkillMetadata(ctx, chat.OwnerID, logger)
return nil
})
g2.Go(func() error {
resolvedUserPrompt = server.resolveUserPrompt(ctx, chat.OwnerID)
return nil
})
if len(mcpConnectConfigs) > 0 {
g2.Go(func() error {
mcpTokens = server.refreshExpiredMCPTokens(ctx, logger, mcpConnectConfigs, mcpTokens)
mcpTools, mcpCleanup = mcpclient.ConnectAll(
ctx,
logger,
mcpConnectConfigs,
mcpTokens,
chat.OwnerID,
server.oidcTokenSource,
chatprovider.CoderHeaders(chat),
)
return nil
})
}
if chat.WorkspaceID.Valid && !isPlanModeTurn && !isExploreSubagent {
g2.Go(func() error {
workspaceMCPTools = server.resolveWorkspaceMCPTools(ctx, logger, chat, &workspaceCtx)
return nil
})
}
// Resolve the per-chat plan path block in the parallel phase. It dials
// the workspace agent to read the home directory, so running it here lets
// the cold dial overlap with the rest of turn preparation instead of
// blocking system prompt assembly on a sequential dial. Best-effort:
// resolvePlanPathBlock logs and returns an empty block on failure.
if chat.WorkspaceID.Valid && !chat.ParentChatID.Valid {
g2.Go(func() error {
planPathBlock = resolvePlanPathBlock(ctx)
return nil
})
}
if err := g2.Wait(); err != nil {
cleanup()
return generationPrepared{}, err
}
if mcpCleanup != nil {
previousCleanup := cleanup
cleanup = func() {
mcpCleanup()
previousCleanup()
}
}
prompt, sanitizeStats := chatsanitize.SanitizeAnthropicProviderToolHistory(model.Provider(), prompt)
chatsanitize.LogAnthropicProviderToolSanitization(
ctx,
logger,
"persisted_history_replay",
model.Provider(),
model.Model(),
sanitizeStats,
)
subagentInstruction := ""
if !isRootChat {
subagentInstruction = defaultSubagentInstruction
}
resolvedSkillsFor := func(workspaceSkills []chattool.SkillMeta) []skillspkg.ResolvedSkill {
return mergeTurnSkills(personalSkills, workspaceSkills)
}
resolveSkillAlias := func(alias string) (skillspkg.ResolvedSkill, error) {
return skillspkg.Lookup(resolvedSkillsFor(workspaceSkills), alias)
}
initialResolvedSkills := resolvedSkillsFor(workspaceSkills)
prompt = buildSystemPrompt(
prompt,
subagentInstruction,
instruction,
initialResolvedSkills,
resolvedUserPrompt,
systemPromptBehaviorContext{
planMode: currentPlanMode,
chatMode: chat.Mode,
planModeInstructions: planModeInstructions,
isRootChat: isRootChat,
},
)
if advisorRuntime != nil {
prompt = chatprompt.InsertSystem(prompt, chatadvisor.ParentGuidanceBlock)
}
prompt = renderPlanPathPrompt(prompt, planPathBlock)
setAdvisorPromptSnapshot(prompt)
storeChatAttachment := server.newStoreChatAttachmentFunc(&workspaceCtx)
tools := []fantasy.AgentTool{
chattool.ReadFile(chattool.ReadFileOptions{GetWorkspaceConn: workspaceCtx.getWorkspaceConn}),
chattool.WriteFile(chattool.WriteFileOptions{
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
ResolvePlanPath: resolvePlanPathForTools,
IsPlanTurn: isPlanModeTurn,
}),
chattool.EditFiles(chattool.EditFilesOptions{
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
ResolvePlanPath: resolvePlanPathForTools,
IsPlanTurn: isPlanModeTurn,
}),
chattool.AttachFile(chattool.AttachFileOptions{
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
StoreFile: storeChatAttachment,
}),
chattool.Execute(chattool.ExecuteOptions{GetWorkspaceConn: workspaceCtx.getWorkspaceConn}),
chattool.ProcessOutput(chattool.ProcessToolOptions{GetWorkspaceConn: workspaceCtx.getWorkspaceConn}),
chattool.ProcessList(chattool.ProcessToolOptions{GetWorkspaceConn: workspaceCtx.getWorkspaceConn}),
chattool.ProcessSignal(chattool.ProcessToolOptions{GetWorkspaceConn: workspaceCtx.getWorkspaceConn}),
}
if isPlanModeTurn && isRootChat {
tools = append(tools, chattool.NewAskUserQuestionTool())
}
if isRootChat {
tools = server.appendRootChatTools(ctx, tools, rootChatToolsOptions{
chat: chat,
modelConfigID: modelConfig.ID,
workspaceCtx: &workspaceCtx,
workspaceMu: &workspaceMu,
resolvePlanPath: resolvePlanPathForTools,
storeFile: storeChatAttachment,
isPlanModeTurn: isPlanModeTurn,
})
}
skillOpts := chattool.ReadSkillOptions{
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
GetSkills: func() []chattool.SkillMeta {
return workspaceSkills
},
ResolveAlias: resolveSkillAlias,
LoadPersonalSkillBody: func(ctx context.Context, name string) (skillspkg.ParsedSkill, error) {
return server.loadPersonalSkillBody(ctx, chat.OwnerID, name)
},
}
appendCurrentSkillTools := func(current []fantasy.AgentTool) ([]fantasy.AgentTool, bool) {
if len(personalSkills) == 0 && len(workspaceSkills) == 0 {
return current, false
}
updated := current
changed := false
appendTool := func(tool fantasy.AgentTool) {
name := tool.Info().Name
if slices.ContainsFunc(current, func(existing fantasy.AgentTool) bool {
return existing.Info().Name == name
}) {
return
}
if !changed {
updated = slices.Clone(current)
changed = true
}
updated = append(updated, tool)
}
appendTool(chattool.ReadSkill(skillOpts))
if len(workspaceSkills) > 0 {
appendTool(chattool.ReadSkillFile(skillOpts))
}
return updated, changed
}
tools, _ = appendCurrentSkillTools(tools)
if advisorRuntime != nil {
tools = append(tools, chatadvisor.Tool(chatadvisor.ToolOptions{
Runtime: advisorRuntime,
GetConversationSnapshot: func() []fantasy.Message {
return stripAdvisorGuidanceBlock(slices.Clone(advisorPromptSnapshot))
},
}))
}
var exclusiveToolNames map[string]bool
if advisorRuntime != nil {
exclusiveToolNames = map[string]bool{chatadvisor.ToolName: true}
}
builtinToolNames := make(map[string]bool, len(tools))
for _, t := range tools {
builtinToolNames[t.Info().Name] = true
}
tools = append(tools, mcpTools...)
if !isExploreSubagent {
tools = append(tools, workspaceMCPTools...)
}
tools = filterToolsForTurn(tools, currentPlanMode, chat.ParentChatID, approvedPlanMCPConfigIDs)
tools, dynamicToolNames, err := appendDynamicTools(ctx, logger, tools, chat.DynamicTools, currentPlanMode, chat.Mode)
if err != nil {
cleanup()
return generationPrepared{}, err
}
var providerTools []chatloop.ProviderTool
if !isPlanModeTurn && callConfig.ProviderOptions != nil {
providerTools = buildProviderTools(callConfig.ProviderOptions)
if isExploreSubagent {
if !chat.ParentChatID.Valid {
providerTools = nil
} else {
providerTools = slices.DeleteFunc(providerTools, func(tool chatloop.ProviderTool) bool {
return tool.Definition.GetName() != "web_search"
})
}
}
}
isComputerUse := chat.Mode.Valid && chat.Mode.ChatMode == database.ChatModeComputerUse
if isComputerUse {
computerUseProvider, computerUseModelProvider, computerUseModelName, err := server.computerUseProviderAndModelFromConfig(ctx)
if err != nil {
cleanup()
return generationPrepared{}, xerrors.Errorf("resolve computer use provider and model: %w", err)
}
computerUseRoute, keyErr := server.resolveModelRouteForProviderType(ctx, chat.OwnerID, computerUseModelProvider)
if keyErr != nil {
cleanup()
return generationPrepared{}, xerrors.Errorf("resolve computer use provider route: %w", keyErr)
}
modelRoute = computerUseRoute
cuModel, cuDebugEnabled, cuResolvedProvider, cuResolvedModel, cuErr := server.resolveComputerUseModel(
ctx,
chat,
computerUseRoute,
computerUseProvider,
computerUseModelProvider,
computerUseModelName,
modelOpts,
)
if cuErr != nil {
cleanup()
return generationPrepared{}, cuErr
}
model = cuModel
debugEnabled = cuDebugEnabled
resolvedProvider = cuResolvedProvider
debugModel = cuResolvedModel
providerTools, err = appendComputerUseProviderTool(providerTools, computerUseProviderToolOptions{
provider: computerUseProvider,
isPlanModeTurn: isPlanModeTurn,
isComputerUse: isComputerUse,
getWorkspaceConn: workspaceCtx.getWorkspaceConn,
storeFile: storeChatAttachment,
clock: server.clock,
logger: server.logger.Named("computer_use"),
})
if err != nil {
cleanup()
return generationPrepared{}, xerrors.Errorf("register computer use provider tool for provider %q: %w", computerUseProvider, err)
}
} else {
providerTools, err = appendComputerUseProviderTool(providerTools, computerUseProviderToolOptions{
isPlanModeTurn: isPlanModeTurn,
isComputerUse: false,
})
if err != nil {
cleanup()
return generationPrepared{}, err
}
}
var requestedEffort *string
if chat.LastReasoningEffort.Valid {
requestedEffort = new(string(chat.LastReasoningEffort.ChatReasoningEffort))
}
reasoningEffort := chatprovider.ResolveReasoningEffort(
requestedEffort,
callConfig.ReasoningEffort,
)
providerOptions := chatprovider.ProviderOptionsFromChatModelConfig(model, callConfig.ProviderOptions)
providerOptions = chatprovider.ApplyReasoningEffort(model, providerOptions, reasoningEffort)
activeToolNames := activeToolNamesForTurn(tools, currentPlanMode, chat.ParentChatID, approvedPlanMCPConfigIDs)
if isExploreSubagent {
activeToolNames = allowedExploreToolNames(tools)
}
toolNameToConfigID := make(map[string]uuid.UUID)
for _, t := range tools {
if mcpTool, ok := t.(mcpclient.MCPToolIdentifier); ok {
toolNameToConfigID[t.Info().Name] = mcpTool.MCPServerConfigID()
}
}
triggerMessageID, historyTipMessageID, triggerLabel := deriveChatDebugSeed(promptRows)
debugSvc := server.existingDebugService()
var debug *generationDebug
if debugEnabled {
if debugSvc == nil {
cleanup()
return generationPrepared{}, xerrors.New("chat debug service missing after enablement check")
}
debug = &generationDebug{
Enabled: true,
Service: debugSvc,
Provider: resolvedProvider,
Model: debugModel,
TriggerMessageID: triggerMessageID,
HistoryTipMessageID: historyTipMessageID,
TriggerLabel: triggerLabel,
ModelConfig: modelConfig,
}
}
compactionToolCallID := "chat_summarized_" + uuid.NewString()
effectiveThreshold := modelConfig.CompressionThreshold
if override, ok := server.resolveUserCompactionThreshold(ctx, chat.OwnerID, modelConfig.ID); ok {
effectiveThreshold = override
}
compactionOptions := chatloop.GenerateCompactionOptions{
Model: model,
Messages: prompt,
ThresholdPercent: effectiveThreshold,
ContextLimit: modelConfig.ContextLimit,
ContextLimitFallback: modelConfig.ContextLimit,
ToolCallID: compactionToolCallID,
ToolName: "chat_summarized",
DebugSvc: debugSvc,
ChatID: chat.ID,
HistoryTipMessageID: historyTipMessageID,
}
compactionOptions.StepUsage = latestPromptUsage(promptRows)
compactionNeeded := shouldCompactPromptUsage(compactionOptions.StepUsage, modelConfig.ContextLimit, effectiveThreshold)
// workspaceCtx.currentChatSnapshot may carry a freshly persisted
// AgentID/BuildID binding from the getWorkspaceAgent call above.
// Return that snapshot so downstream consumers see the up-to-date
// metadata.
refreshedChat := workspaceCtx.currentChatSnapshot()
if refreshedChat.ID == uuid.Nil {
refreshedChat = chat
}
return generationPrepared{
Chat: refreshedChat,
Messages: input.Messages,
Model: model,
Prompt: prompt,
Tools: tools,
ActiveTools: activeToolNames,
ProviderTools: providerTools,
ModelRoute: modelRoute,
ModelBuildOptions: modelOpts,
ResolvedProvider: resolvedProvider,
ModelConfigID: modelConfig.ID,
ModelConfig: callConfig,
ProviderOptions: providerOptions,
ContextLimitFallback: modelConfig.ContextLimit,
DynamicToolNames: dynamicToolNames,
StopAfterTools: stopAfterBehaviorTools(currentPlanMode, chat.Mode, chat.ParentChatID),
ExclusiveToolNames: exclusiveToolNames,
BuiltinToolNames: builtinToolNames,
ToolNameToConfigID: toolNameToConfigID,
MaxSteps: maxChatSteps,
Compaction: &generationCompaction{
Required: compactionNeeded,
Options: compactionOptions,
},
Cleanup: cleanup,
Debug: debug,
}, nil
}
func latestPromptUsage(messages []database.ChatMessage) fantasy.Usage {
for i := len(messages) - 1; i >= 0; i-- {
usage := usageFromMessage(messages[i])
if usage != (fantasy.Usage{}) {
return usage
}
}
return fantasy.Usage{}
}
func shouldCompactPromptUsage(usage fantasy.Usage, contextLimit int64, thresholdPercent int32) bool {
if thresholdPercent >= 100 || contextLimit <= 0 {
return false
}
contextTokens := contextTokensFromUsage(usage)
if contextTokens <= 0 {
return false
}
usagePercent := (float64(contextTokens) / float64(contextLimit)) * 100
return usagePercent >= float64(thresholdPercent)
}
func contextTokensFromUsage(usage fantasy.Usage) int64 {
total := int64(0)
hasContextTokens := false
if usage.InputTokens > 0 {
total += usage.InputTokens
hasContextTokens = true
}
if usage.CacheReadTokens > 0 {
total += usage.CacheReadTokens
hasContextTokens = true
}
if usage.CacheCreationTokens > 0 {
total += usage.CacheCreationTokens
hasContextTokens = true
}
if !hasContextTokens && usage.TotalTokens > 0 {
total = usage.TotalTokens
}
return total
}
func (server *Server) afterInterruptionOutcome(
ctx context.Context,
outcome interruptionOutcome,
) error {
chat := outcome.Chat
logger := server.logger.With(slog.F("chat_id", chat.ID), slog.F("owner_id", chat.OwnerID))
if outcome.Kind == runnerActionKindFinishInterruption {
server.maybeClearLastTurnSummaryAsync(context.WithoutCancel(ctx), chat, logger)
}
return nil
}
func (server *Server) afterGenerationOutcome(
ctx context.Context,
outcome generationOutcome,
) error {
chat := outcome.Chat
logger := server.logger.With(slog.F("chat_id", chat.ID), slog.F("owner_id", chat.OwnerID))
switch outcome.Kind {
case runnerActionKindFinishTurn:
finalizeCtx := context.WithoutCancel(ctx)
runResult := server.deriveFinalTurnRunResult(finalizeCtx, chat, logger)
server.maybeFinalizeTurnStatusLabelAndPush(finalizeCtx, chat, chat.Status, "", runResult, logger)
case runnerActionKindFinishError:
server.maybeFinalizeTurnStatusLabelAndPush(context.WithoutCancel(ctx), chat, chat.Status, outcome.LastError, runChatResult{}, logger)
case runnerActionKindEnterRequiresAction:
server.maybeFinalizeTurnStatusLabelAndPush(context.WithoutCancel(ctx), chat, chat.Status, "", runChatResult{}, logger)
}
return nil
}
// deriveFinalTurnRunResult rebuilds the inputs needed to generate the
// end-of-turn status label directly from persisted state.
func (server *Server) deriveFinalTurnRunResult(
ctx context.Context,
chat database.Chat,
logger slog.Logger,
) runChatResult {
// generateFinalTurnStatusLabel only produces a model-generated label for
// the Waiting status, so skip the model resolution and history read
// otherwise.
if chat.Status != database.ChatStatusWaiting {
return runChatResult{}
}
promptRows, err := server.db.GetChatMessagesForPromptByChatID(ctx, chat.ID)
if err != nil {
logger.Warn(ctx, "derive final turn status label: load prompt rows", slog.Error(err))
return runChatResult{}
}
triggerMessageID, historyTipMessageID, _ := deriveChatDebugSeed(promptRows)
finalAssistantText := latestAssistantText(promptRows)
if finalAssistantText == "" {
return runChatResult{}
}
// resolvedProvider/resolvedModel describe the model the fallback handle was
// built from; they only feed the status-label fallback candidate's labels.
modelOpts := modelBuildOptionsFromMessages(promptRows)
ctx = withActiveTurnAPIKeyID(ctx, modelOpts)
model, _, modelRoute, _, resolvedProvider, resolvedModel, err := server.resolveChatModel(ctx, chat, modelOpts)
if err != nil {
// Return what we have; generateFinalTurnStatusLabel falls back to a
// generic label when StatusLabelModel is nil.
logger.Warn(ctx, "derive final turn status label: resolve model", slog.Error(err))
return runChatResult{
FinalAssistantText: finalAssistantText,
TriggerMessageID: triggerMessageID,
HistoryTipMessageID: historyTipMessageID,
}
}
return runChatResult{
FinalAssistantText: finalAssistantText,
StatusLabelModel: model,
FallbackProvider: resolvedProvider,
FallbackRoute: modelRoute,
FallbackModel: resolvedModel,
ModelBuildOptions: modelOpts,
TriggerMessageID: triggerMessageID,
HistoryTipMessageID: historyTipMessageID,
}
}
// latestAssistantText returns the trimmed text of the most recent assistant
// message. It mirrors the FinalAssistantText that buildCommitStepMessages
// produced from the freshly generated step, making persisted history the
// single source of truth for the turn status label input.
func latestAssistantText(messages []database.ChatMessage) string {
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role != database.ChatMessageRoleAssistant {
continue
}
parts, err := chatprompt.ParseContent(messages[i])
if err != nil {
return ""
}
return strings.TrimSpace(textFromParts(parts))
}
return ""
}