mirror of
https://github.com/coder/coder.git
synced 2026-09-22 21:22:17 +08:00
783 lines
25 KiB
Go
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: ¤tChat,
|
|
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 ""
|
|
}
|