mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
1652 lines
50 KiB
Go
1652 lines
50 KiB
Go
package chatd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"charm.land/fantasy"
|
|
"charm.land/fantasy/object"
|
|
fantasyanthropic "charm.land/fantasy/providers/anthropic"
|
|
fantasyazure "charm.land/fantasy/providers/azure"
|
|
fantasybedrock "charm.land/fantasy/providers/bedrock"
|
|
fantasygoogle "charm.land/fantasy/providers/google"
|
|
fantasyopenai "charm.land/fantasy/providers/openai"
|
|
fantasyopenrouter "charm.land/fantasy/providers/openrouter"
|
|
fantasyvercel "charm.land/fantasy/providers/vercel"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chatretry"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
const titleGenerationPrompt = "Write a short title for the user's message. " +
|
|
"Populate the title field with the result. " +
|
|
"Keep the title to 2-8 words. " +
|
|
"Do not answer the user or describe the title-writing task. " +
|
|
"Preserve specific identifiers such as PR numbers, repo names, file paths, function names, and error messages. " +
|
|
"If the message is short or vague, stay close to the user's wording instead of inventing context. " +
|
|
"Write the title in the same language as the user's message. " +
|
|
"Sentence case. No quotes, emoji, markdown, or trailing punctuation.\n\n" +
|
|
"Examples:\n" +
|
|
"Message: how do I set up SSO with Okta?\n" +
|
|
"Title: Set up SSO with Okta\n\n" +
|
|
"Message: getting `pq: duplicate key value violates unique constraint` when running migrations in coderd\n" +
|
|
"Title: Fix pq duplicate key violation in coderd migrations\n\n" +
|
|
"Message: review PR #123 in acme/webapp and flag risky changes\n" +
|
|
"Title: Review risky changes in acme/webapp PR #123\n\n" +
|
|
"Message: corrige el error de compilación en main.go\n" +
|
|
"Title: Corregir error de compilación en main.go\n\n" +
|
|
"Message: help\n" +
|
|
"Title: Help request"
|
|
|
|
// quickgenTemperature keeps title and status-label output stable
|
|
// across repeated runs over the same input. Fantasy providers drop
|
|
// this with a call warning for models that reject it (OpenAI
|
|
// reasoning models, Anthropic thinking models), but only for model
|
|
// names they recognize. generateQuickgenObject handles models that
|
|
// reject the parameter at the API instead.
|
|
const quickgenTemperature = 0.0
|
|
|
|
// generateQuickgenObject generates a structured object with provider
|
|
// retries and the pinned quickgen temperature. Model aliases served
|
|
// through gateways such as AI Bridge are not recognized by fantasy's
|
|
// per-model parameter stripping and can reject temperature with a
|
|
// bad-request error, so the call is retried without temperature when
|
|
// the model rejects it.
|
|
func generateQuickgenObject[T any](
|
|
ctx context.Context,
|
|
model fantasy.LanguageModel,
|
|
call fantasy.ObjectCall,
|
|
) (*fantasy.ObjectResult[T], error) {
|
|
call.Temperature = new(quickgenTemperature)
|
|
var result *fantasy.ObjectResult[T]
|
|
err := chatretry.Retry(ctx, func(retryCtx context.Context) error {
|
|
var genErr error
|
|
result, genErr = generateObject[T](retryCtx, model, call)
|
|
if call.Temperature != nil && isTemperatureRejectedError(genErr) {
|
|
// The model rejects the temperature parameter. Drop it
|
|
// for this and any later retry attempts.
|
|
call.Temperature = nil
|
|
result, genErr = generateObject[T](retryCtx, model, call)
|
|
}
|
|
return genErr
|
|
}, nil)
|
|
return result, err
|
|
}
|
|
|
|
// isTemperatureRejectedError reports whether a provider rejected the
|
|
// request because the model does not accept the temperature parameter,
|
|
// for example Anthropic's "`temperature` is deprecated for this model."
|
|
// or OpenAI's "Unsupported parameter: 'temperature' is not supported
|
|
// with this model.". Quickgen only sends a valid temperature value, so
|
|
// any bad-request response mentioning temperature means the model
|
|
// rejects the parameter itself.
|
|
func isTemperatureRejectedError(err error) bool {
|
|
var providerErr *fantasy.ProviderError
|
|
if !errors.As(err, &providerErr) {
|
|
return false
|
|
}
|
|
if providerErr.StatusCode != http.StatusBadRequest {
|
|
return false
|
|
}
|
|
text := strings.ToLower(providerErr.Error() + " " + string(providerErr.ResponseBody))
|
|
return strings.Contains(text, "temperature")
|
|
}
|
|
|
|
// generateObject generates a structured object, falling back to
|
|
// text-mode generation when the provider rejects the forced tool_choice
|
|
// that tool-mode generation sends. Some models, such as Claude Opus 5.5,
|
|
// return a bad request for tool_choice "tool" and "any", and gateway
|
|
// model aliases cannot be matched by name.
|
|
func generateObject[T any](
|
|
ctx context.Context,
|
|
model fantasy.LanguageModel,
|
|
call fantasy.ObjectCall,
|
|
) (*fantasy.ObjectResult[T], error) {
|
|
result, err := object.Generate[T](ctx, model, call)
|
|
if isToolChoiceRejectedError(err) {
|
|
return object.Generate[T](ctx, textObjectModel{LanguageModel: model}, call)
|
|
}
|
|
return result, err
|
|
}
|
|
|
|
// textObjectModel generates objects by parsing a JSON text response
|
|
// instead of forcing a tool call.
|
|
type textObjectModel struct {
|
|
fantasy.LanguageModel
|
|
}
|
|
|
|
func (m textObjectModel) GenerateObject(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
|
|
return object.GenerateWithText(ctx, m.LanguageModel, call)
|
|
}
|
|
|
|
// isToolChoiceRejectedError reports whether a provider rejected the
|
|
// request because the model does not accept a forced tool_choice, for
|
|
// example Anthropic's "tool_choice: type \"tool\" and \"any\" are not
|
|
// supported for this model.".
|
|
func isToolChoiceRejectedError(err error) bool {
|
|
var providerErr *fantasy.ProviderError
|
|
if !errors.As(err, &providerErr) {
|
|
return false
|
|
}
|
|
if providerErr.StatusCode != http.StatusBadRequest {
|
|
return false
|
|
}
|
|
text := strings.ToLower(providerErr.Error() + " " + string(providerErr.ResponseBody))
|
|
return strings.Contains(text, "tool_choice")
|
|
}
|
|
|
|
const (
|
|
// maxConversationContextRunes caps the conversation sample in manual
|
|
// title prompts to avoid exceeding model context windows.
|
|
maxConversationContextRunes = 6000
|
|
// maxLatestUserMessageRunes caps the latest user message excerpt.
|
|
maxLatestUserMessageRunes = 1000
|
|
// recentTurnWindow is the number of most recent turns included
|
|
// alongside the first user turn in manual title context.
|
|
recentTurnWindow = 3
|
|
)
|
|
|
|
// preferredTitleModels are lightweight models used for title
|
|
// generation, one per provider type. Each entry uses the
|
|
// cheapest/fastest small model for that provider as identified
|
|
// by the charmbracelet/catwalk model catalog. Providers that
|
|
// aren't configured (no API key) are silently skipped.
|
|
var preferredTitleModels = []struct {
|
|
provider string
|
|
model string
|
|
}{
|
|
{fantasyanthropic.Name, "claude-haiku-4-5"},
|
|
{fantasyopenai.Name, "gpt-4o-mini"},
|
|
{fantasygoogle.Name, "gemini-2.5-flash"},
|
|
{fantasyazure.Name, "gpt-4o-mini"},
|
|
{fantasybedrock.Name, "global.anthropic.claude-haiku-4-5-20251001-v1:0"},
|
|
{fantasyopenrouter.Name, "anthropic/claude-3.5-haiku"},
|
|
{fantasyvercel.Name, "anthropic/claude-haiku-4.5"},
|
|
}
|
|
|
|
// Debug attribution uses configured identities for title calls and the
|
|
// resolved identity for status-label calls.
|
|
type shortTextCandidate struct {
|
|
provider string
|
|
model string
|
|
resolved resolvedModelCall
|
|
}
|
|
|
|
// modelSnapshotSuffix matches a dated snapshot suffix on a model ID, such as
|
|
// claude-haiku-4-5-20251001 or gpt-4o-mini-2024-07-18, which admins commonly
|
|
// configure in place of the alias.
|
|
var modelSnapshotSuffix = regexp.MustCompile(`-(\d{8}|\d{4}-\d{2}-\d{2})$`)
|
|
|
|
func selectPreferredConfiguredShortTextModelConfig(
|
|
configs []database.GetEnabledChatModelConfigsByOrganizationRow,
|
|
) (database.ChatModelConfig, bool) {
|
|
for _, preferred := range preferredTitleModels {
|
|
for _, config := range configs {
|
|
if chatprovider.NormalizeProvider(config.Provider) != preferred.provider {
|
|
continue
|
|
}
|
|
model := strings.TrimSpace(config.ChatModelConfig.Model)
|
|
if !strings.EqualFold(model, preferred.model) &&
|
|
!strings.EqualFold(modelSnapshotSuffix.ReplaceAllString(model, ""), preferred.model) {
|
|
continue
|
|
}
|
|
return config.ChatModelConfig, true
|
|
}
|
|
}
|
|
return database.ChatModelConfig{}, false
|
|
}
|
|
|
|
// resolveQuickgenModel resolves the model for a quickgen side call (title,
|
|
// turn status label, chat summary). It prefers the organization's title
|
|
// generation model override, then an enabled preferred small model, and uses
|
|
// the chat's own model only as a last resort, so side calls avoid chat models
|
|
// that cannot serve structured generations. A configured but unusable
|
|
// override is a hard error. purpose labels resolver logs only.
|
|
func (p *Server) resolveQuickgenModel(
|
|
ctx context.Context,
|
|
purpose string,
|
|
chat database.Chat,
|
|
modelOpts modelBuildOptions,
|
|
) (resolvedModelCall, error) {
|
|
override, overrideErr := p.resolveModelOverride(ctx, modelOverrideSpec{
|
|
context: titleGenerationOverrideContext,
|
|
ownerID: chat.OwnerID,
|
|
organizationID: chat.OrganizationID,
|
|
queryFailure: modelOverrideFailureModeHard,
|
|
configFailure: modelOverrideFailureModeHard,
|
|
providerFailure: modelOverrideFailureModeHard,
|
|
})
|
|
if overrideErr != nil {
|
|
if override.Set {
|
|
return resolvedModelCall{}, xerrors.Errorf(
|
|
"resolve title generation model override for %s: %w",
|
|
purpose,
|
|
overrideErr,
|
|
)
|
|
}
|
|
p.logger.Debug(ctx, "failed to resolve title generation model override",
|
|
slog.F("purpose", purpose),
|
|
slog.F("chat_id", chat.ID),
|
|
slog.Error(overrideErr),
|
|
)
|
|
} else if override.Set {
|
|
resolved, err := p.resolveModelCall(ctx, modelCallSpec{
|
|
purpose: purpose,
|
|
chat: chat,
|
|
explicitConfig: &override.Config,
|
|
requestedEffort: override.ReasoningEffort,
|
|
chatdScopedRoute: true,
|
|
buildOptions: modelOpts,
|
|
})
|
|
if err != nil {
|
|
return resolvedModelCall{}, xerrors.Errorf(
|
|
"resolve title generation model override for %s: %w",
|
|
purpose,
|
|
err,
|
|
)
|
|
}
|
|
return resolved, nil
|
|
}
|
|
|
|
modelCtx, err := p.callerModelConfigContext(ctx, chat.OwnerID)
|
|
if err != nil {
|
|
return resolvedModelCall{}, err
|
|
}
|
|
configs, err := enabledChatModelConfigsForOrganization(modelCtx, p.db, chat.OrganizationID)
|
|
if err != nil {
|
|
p.logger.Debug(ctx, "failed to list quickgen model configs",
|
|
slog.F("purpose", purpose),
|
|
slog.F("chat_id", chat.ID),
|
|
slog.Error(err),
|
|
)
|
|
return p.resolveQuickgenChatModelFallback(ctx, purpose, chat, modelOpts)
|
|
}
|
|
|
|
config, ok := selectPreferredConfiguredShortTextModelConfig(configs)
|
|
if !ok {
|
|
return p.resolveQuickgenChatModelFallback(ctx, purpose, chat, modelOpts)
|
|
}
|
|
|
|
resolved, err := p.resolveModelCall(ctx, modelCallSpec{
|
|
purpose: purpose,
|
|
chat: chat,
|
|
explicitConfig: &config,
|
|
buildOptions: modelOpts,
|
|
})
|
|
if err != nil {
|
|
p.logger.Debug(ctx, "preferred quickgen model unavailable",
|
|
slog.F("purpose", purpose),
|
|
slog.F("chat_id", chat.ID),
|
|
slog.F("model", config.Model),
|
|
slog.Error(err),
|
|
)
|
|
return p.resolveQuickgenChatModelFallback(ctx, purpose, chat, modelOpts)
|
|
}
|
|
return resolved, nil
|
|
}
|
|
|
|
// resolveQuickgenChatModelFallback resolves the chat's own model as the last
|
|
// resort for a quickgen side call.
|
|
func (p *Server) resolveQuickgenChatModelFallback(
|
|
ctx context.Context,
|
|
purpose string,
|
|
chat database.Chat,
|
|
modelOpts modelBuildOptions,
|
|
) (resolvedModelCall, error) {
|
|
resolved, err := p.resolveModelCall(ctx, modelCallSpec{
|
|
purpose: purpose,
|
|
chat: chat,
|
|
buildOptions: modelOpts,
|
|
})
|
|
if err != nil {
|
|
return resolvedModelCall{}, xerrors.Errorf(
|
|
"resolve fallback chat model for %s: %w",
|
|
purpose,
|
|
err,
|
|
)
|
|
}
|
|
return resolved, nil
|
|
}
|
|
|
|
func normalizeShortTextOutput(text string) string {
|
|
text = strings.TrimSpace(text)
|
|
if text == "" {
|
|
return ""
|
|
}
|
|
|
|
text = strings.Trim(text, "\"'`")
|
|
return strings.Join(strings.Fields(text), " ")
|
|
}
|
|
|
|
type generatedTitle struct {
|
|
Title string `json:"title" description:"Short descriptive chat title"`
|
|
}
|
|
|
|
type generatedTurnStatusLabel struct {
|
|
Label string `json:"label" description:"Compact 2-5 word current chat status label"`
|
|
}
|
|
|
|
// GenerateChatTitleAsync fires a best-effort, automatic title-generation
|
|
// pass for a freshly created chat. It is intended to be called from the
|
|
// chat-creation endpoint right after the chat and its initial user
|
|
// message are persisted.
|
|
//
|
|
// The work runs in a tracked goroutine with a context detached from the
|
|
// request but bound to the server: it neither blocks the HTTP response
|
|
// nor is canceled when the request completes, and Close cancels it
|
|
// instead of blocking on the title timeout while a provider is
|
|
// unreachable. It resolves the title generation model (see
|
|
// resolveQuickgenModel), then delegates to maybeGenerateChatTitle, which
|
|
// only acts on the first user turn (see titleInput) and is otherwise a
|
|
// no-op. Errors are logged and swallowed.
|
|
func (p *Server) GenerateChatTitleAsync(ctx context.Context, chat database.Chat) {
|
|
logger := p.logger.With(
|
|
slog.F("chat_id", chat.ID),
|
|
slog.F("owner_id", chat.OwnerID),
|
|
)
|
|
// Snapshot the messages synchronously so the first-turn eligibility
|
|
// check (titleInput) is evaluated against creation-time state. Loading
|
|
// inside the goroutine would race the chat worker's first assistant
|
|
// reply and could skip title generation.
|
|
messages, err := p.db.GetChatMessagesForPromptByChatID(ctx, chat.ID)
|
|
if err != nil {
|
|
logger.Debug(ctx, "failed to load messages for automatic title generation",
|
|
slog.Error(err),
|
|
)
|
|
return
|
|
}
|
|
pasteText, err := titlePasteText(ctx, p.db, messages)
|
|
if err != nil {
|
|
logger.Debug(ctx, "failed to load pasted-text attachments for automatic title generation",
|
|
slog.Error(err),
|
|
)
|
|
return
|
|
}
|
|
if _, ok := titleInput(chat, messages, pasteText); !ok {
|
|
return
|
|
}
|
|
// Detach from request; bind to server so Close cancels it.
|
|
titleCtx, stopTitleCtx := p.inflightContext(ctx)
|
|
if err := p.goInflight(func() {
|
|
defer stopTitleCtx()
|
|
apiKeyID, err := p.ensureSyntheticAPIKeyID(titleCtx, chat.OwnerID)
|
|
if err != nil {
|
|
logger.Debug(titleCtx, "failed to ensure synthetic API key for automatic title generation", slog.Error(err))
|
|
return
|
|
}
|
|
modelOpts := modelBuildOptions{ActiveAPIKeyID: apiKeyID}
|
|
resolved, err := p.resolveQuickgenModel(titleCtx, "title", chat, modelOpts)
|
|
if err != nil {
|
|
logger.Warn(titleCtx, "failed to resolve model for automatic title generation",
|
|
slog.Error(err),
|
|
)
|
|
return
|
|
}
|
|
p.maybeGenerateChatTitle(
|
|
titleCtx,
|
|
chat,
|
|
messages,
|
|
pasteText,
|
|
resolved,
|
|
&generatedChatTitle{},
|
|
logger,
|
|
p.existingDebugService(),
|
|
)
|
|
}); err != nil {
|
|
stopTitleCtx()
|
|
logger.Error(context.WithoutCancel(ctx), "failed to schedule automatic chat title generation",
|
|
slog.F("chat_id", chat.ID),
|
|
slog.F("owner_id", chat.OwnerID),
|
|
slog.Error(err),
|
|
)
|
|
}
|
|
}
|
|
|
|
// maybeGenerateChatTitle generates an AI title for the chat when
|
|
// appropriate (first user message, no assistant reply yet, and the
|
|
// current title is either empty or still the fallback truncation) using
|
|
// the resolved title generation model (see resolveQuickgenModel). It is a
|
|
// best-effort operation that logs and swallows errors.
|
|
func (p *Server) maybeGenerateChatTitle(
|
|
ctx context.Context,
|
|
chat database.Chat,
|
|
messages []database.ChatMessage,
|
|
pasteText map[uuid.UUID]string,
|
|
resolved resolvedModelCall,
|
|
generatedTitle *generatedChatTitle,
|
|
logger slog.Logger,
|
|
debugSvc *chatdebug.Service,
|
|
) {
|
|
input, ok := titleInput(chat, messages, pasteText)
|
|
if !ok {
|
|
return
|
|
}
|
|
debugEnabled := debugSvc != nil && debugSvc.IsEnabled(ctx, chat.ID, chat.OwnerID)
|
|
|
|
titleCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
|
|
candidate := shortTextCandidate{
|
|
provider: string(resolved.route.Provider.Type),
|
|
model: resolved.dbConfig.Model,
|
|
resolved: resolved,
|
|
}
|
|
|
|
var historyTipMessageID int64
|
|
if len(messages) > 0 {
|
|
historyTipMessageID = messages[len(messages)-1].ID
|
|
}
|
|
|
|
var triggerMessageID int64
|
|
for _, message := range messages {
|
|
if message.Visibility == database.ChatMessageVisibilityModel {
|
|
continue
|
|
}
|
|
if message.Role == database.ChatMessageRoleUser {
|
|
triggerMessageID = message.ID
|
|
break
|
|
}
|
|
}
|
|
|
|
seedSummary := chatdebug.SeedSummary(
|
|
chatdebug.TruncateLabel(input, chatdebug.MaxLabelLength),
|
|
)
|
|
|
|
candidateCtx := titleCtx
|
|
finishDebugRun := func(error) {}
|
|
if debugEnabled {
|
|
candidateCtx, finishDebugRun = prepareQuickgenDebugCandidate(
|
|
titleCtx,
|
|
chat,
|
|
debugSvc,
|
|
candidate,
|
|
chatdebug.KindTitleGeneration,
|
|
triggerMessageID,
|
|
historyTipMessageID,
|
|
seedSummary,
|
|
logger,
|
|
)
|
|
}
|
|
|
|
title, err := generateTitle(candidateCtx, candidate.resolved.model.LanguageModel(), titleObjectCall(candidate.resolved), input)
|
|
finishDebugRun(err)
|
|
if err != nil {
|
|
logger.Warn(ctx, "title model candidate failed",
|
|
slog.F("chat_id", chat.ID),
|
|
slog.F("provider", resolved.resolvedProvider),
|
|
slog.F("model", resolved.resolvedModel),
|
|
slog.Error(err),
|
|
)
|
|
return
|
|
}
|
|
if title == "" || title == chat.Title {
|
|
return
|
|
}
|
|
|
|
_, err = p.db.UpdateChatTitleByID(ctx, database.UpdateChatTitleByIDParams{
|
|
ID: chat.ID,
|
|
Title: title,
|
|
})
|
|
if err != nil {
|
|
logger.Warn(ctx, "failed to update generated chat title",
|
|
slog.F("chat_id", chat.ID),
|
|
slog.Error(err),
|
|
)
|
|
return
|
|
}
|
|
chat.Title = title
|
|
generatedTitle.Store(title)
|
|
p.publishChatPubsubEvent(chat, codersdk.ChatWatchEventKindTitleChange, nil)
|
|
}
|
|
|
|
// Quickgen caps leave room for adaptive thinking, which counts toward the cap
|
|
// on models that think by default (Claude 5+). Keep them at or below 1024:
|
|
// for older Claude models with a configured effort, fantasy derives a
|
|
// budget_tokens thinking budget from the cap and enables thinking once it
|
|
// reaches 1024, and Anthropic rejects thinking with tool-mode generation's
|
|
// forced tool_choice.
|
|
const titleMaxOutputTokens = int64(1024)
|
|
|
|
func titleObjectCall(resolved resolvedModelCall) fantasy.ObjectCall {
|
|
return resolved.newObjectCall("propose_title", "Propose a short chat title.", titleMaxOutputTokens)
|
|
}
|
|
|
|
func prepareQuickgenDebugCandidate(
|
|
ctx context.Context,
|
|
chat database.Chat,
|
|
debugSvc *chatdebug.Service,
|
|
candidate shortTextCandidate,
|
|
kind chatdebug.RunKind,
|
|
triggerMessageID int64,
|
|
historyTipMessageID int64,
|
|
seedSummary map[string]any,
|
|
logger slog.Logger,
|
|
) (context.Context, func(error)) {
|
|
finishDebugRun := func(error) {}
|
|
|
|
// Debug instrumentation must not eat into the quickgen budget
|
|
// (30s titleCtx / summaryCtx on the caller). Detach and bound
|
|
// the insert so a slow DB can't delay title generation or push
|
|
// summaries, matching prepareManualTitleDebugRun,
|
|
// prepareChatTurnDebugRun, and startCompactionDebugRun.
|
|
createRunCtx, createRunCancel := context.WithTimeout(
|
|
context.WithoutCancel(ctx), debugCreateRunTimeout,
|
|
)
|
|
run, err := debugSvc.CreateRun(createRunCtx, chatdebug.CreateRunParams{
|
|
ChatID: chat.ID,
|
|
TriggerMessageID: triggerMessageID,
|
|
HistoryTipMessageID: historyTipMessageID,
|
|
Kind: kind,
|
|
Status: chatdebug.StatusInProgress,
|
|
Provider: candidate.provider,
|
|
Model: candidate.model,
|
|
Summary: seedSummary,
|
|
})
|
|
createRunCancel()
|
|
if err != nil {
|
|
logger.Warn(ctx, "failed to create short-text debug run",
|
|
slog.F("chat_id", chat.ID),
|
|
slog.F("run_kind", kind),
|
|
slog.F("provider", candidate.provider),
|
|
slog.F("model", candidate.model),
|
|
slog.Error(err),
|
|
)
|
|
return ctx, finishDebugRun
|
|
}
|
|
|
|
runContext := chatdebugRunContext(run)
|
|
runCtx := chatdebug.ContextWithRun(ctx, &runContext)
|
|
finishDebugRun = func(runErr error) {
|
|
if finalizeErr := debugSvc.FinalizeRun(ctx, chatdebug.FinalizeRunParams{
|
|
RunID: run.ID,
|
|
ChatID: chat.ID,
|
|
Status: chatdebug.ClassifyError(runErr),
|
|
SeedSummary: seedSummary,
|
|
Timeout: 10 * time.Second,
|
|
}); finalizeErr != nil {
|
|
logger.Warn(ctx, "failed to finalize short-text debug run",
|
|
slog.F("chat_id", chat.ID),
|
|
slog.F("run_kind", kind),
|
|
slog.F("run_id", run.ID),
|
|
slog.Error(finalizeErr),
|
|
)
|
|
}
|
|
}
|
|
return runCtx, finishDebugRun
|
|
}
|
|
|
|
func quickgenPrompt(systemPrompt, userInput string) fantasy.Prompt {
|
|
return fantasy.Prompt{
|
|
{
|
|
Role: fantasy.MessageRoleSystem,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{Text: systemPrompt},
|
|
},
|
|
},
|
|
{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{Text: userInput},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// generateTitle calls the model with a title-generation system prompt
|
|
// and returns the normalized result. It retries transient LLM errors
|
|
// (rate limits, overloaded, etc.) with exponential backoff.
|
|
func generateTitle(
|
|
ctx context.Context,
|
|
model fantasy.LanguageModel,
|
|
call fantasy.ObjectCall,
|
|
input string,
|
|
) (string, error) {
|
|
title, err := generateStructuredTitle(ctx, model, call, titleGenerationPrompt, input)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return title, nil
|
|
}
|
|
|
|
func generateStructuredTitle(
|
|
ctx context.Context,
|
|
model fantasy.LanguageModel,
|
|
call fantasy.ObjectCall,
|
|
systemPrompt string,
|
|
userInput string,
|
|
) (string, error) {
|
|
title, _, err := generateStructuredTitleWithUsage(
|
|
ctx,
|
|
model,
|
|
call,
|
|
systemPrompt,
|
|
userInput,
|
|
)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return title, nil
|
|
}
|
|
|
|
func generateStructuredTitleWithUsage(
|
|
ctx context.Context,
|
|
model fantasy.LanguageModel,
|
|
call fantasy.ObjectCall,
|
|
systemPrompt string,
|
|
userInput string,
|
|
) (string, fantasy.Usage, error) {
|
|
userInput = strings.TrimSpace(userInput)
|
|
if userInput == "" {
|
|
return "", fantasy.Usage{}, xerrors.New("title input was empty")
|
|
}
|
|
|
|
call.Prompt = quickgenPrompt(systemPrompt, userInput)
|
|
result, err := generateQuickgenObject[generatedTitle](ctx, model, call)
|
|
if err != nil {
|
|
var usage fantasy.Usage
|
|
var noObjErr *fantasy.NoObjectGeneratedError
|
|
if errors.As(err, &noObjErr) {
|
|
usage = noObjErr.Usage
|
|
}
|
|
return "", usage, xerrors.Errorf("generate structured title: %w", err)
|
|
}
|
|
|
|
title := normalizeTitleOutput(result.Object.Title)
|
|
if err := validateGeneratedTitle(title); err != nil {
|
|
return "", result.Usage, err
|
|
}
|
|
return title, result.Usage, nil
|
|
}
|
|
|
|
func validateGeneratedTitle(title string) error {
|
|
if title == "" {
|
|
return xerrors.New("generated title was empty")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// titleInput returns the first user message title text and whether
|
|
// title generation should proceed. It returns false when the chat
|
|
// already has assistant/tool replies, has more than one visible user
|
|
// message, or the current title doesn't look like a candidate for
|
|
// replacement. pasteText carries resolved pasted-text attachment
|
|
// content (see titlePasteText) so paste-only messages stay eligible.
|
|
func titleInput(
|
|
chat database.Chat,
|
|
messages []database.ChatMessage,
|
|
pasteText map[uuid.UUID]string,
|
|
) (string, bool) {
|
|
userCount := 0
|
|
firstUserText := ""
|
|
|
|
for _, message := range messages {
|
|
if message.Visibility == database.ChatMessageVisibilityModel {
|
|
continue
|
|
}
|
|
|
|
switch message.Role {
|
|
case database.ChatMessageRoleAssistant, database.ChatMessageRoleTool:
|
|
return "", false
|
|
case database.ChatMessageRoleUser:
|
|
userCount++
|
|
if firstUserText == "" {
|
|
parsed, err := chatprompt.ParseContent(message)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
firstUserText = chatprompt.TitleText(parsed, pasteText)
|
|
}
|
|
}
|
|
}
|
|
|
|
if userCount != 1 || firstUserText == "" {
|
|
return "", false
|
|
}
|
|
|
|
currentTitle := strings.TrimSpace(chat.Title)
|
|
if currentTitle == "" {
|
|
return firstUserText, true
|
|
}
|
|
|
|
if currentTitle != chatprompt.FallbackTitle(firstUserText) {
|
|
return "", false
|
|
}
|
|
|
|
return firstUserText, true
|
|
}
|
|
|
|
// titlePasteText resolves synthetic pasted-text attachment content
|
|
// for visible user messages whose text and file-reference parts yield
|
|
// no title input, fetching only bounded prefixes for
|
|
// chatprompt.TitleText. It returns nil without touching the database
|
|
// when every user message already has text.
|
|
func titlePasteText(
|
|
ctx context.Context,
|
|
store database.Store,
|
|
messages []database.ChatMessage,
|
|
) (map[uuid.UUID]string, error) {
|
|
var ids []uuid.UUID
|
|
for _, message := range messages {
|
|
if message.Visibility == database.ChatMessageVisibilityModel {
|
|
continue
|
|
}
|
|
if message.Role != database.ChatMessageRoleUser {
|
|
continue
|
|
}
|
|
parsed, err := chatprompt.ParseContent(message)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if chatprompt.TitleText(parsed, nil) != "" {
|
|
continue
|
|
}
|
|
ids = append(ids, chatprompt.SyntheticPasteFileIDs(parsed)...)
|
|
}
|
|
if len(ids) == 0 {
|
|
return nil, nil //nolint:nilnil // Nil map cleanly signals no paste content to resolve.
|
|
}
|
|
|
|
rows, err := store.GetChatFileDataPrefixesByIDs(ctx, database.GetChatFileDataPrefixesByIDsParams{
|
|
IDs: ids,
|
|
PrefixBytes: chatprompt.TitlePasteBytePrefix,
|
|
})
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("get pasted-text chat file prefixes: %w", err)
|
|
}
|
|
pasteText := make(map[uuid.UUID]string, len(rows))
|
|
for _, row := range rows {
|
|
pasteText[row.ID] = chatprompt.TitlePasteText(row.DataPrefix)
|
|
}
|
|
return pasteText, nil
|
|
}
|
|
|
|
// titleMaxWords caps generated titles at the prompt's stated 2-8 word
|
|
// budget. Quickgen pins temperature, so a model that overshoots the
|
|
// budget for a given conversation keeps overshooting on retry; keeping
|
|
// the first words of an otherwise good title beats failing generation.
|
|
const titleMaxWords = 8
|
|
|
|
func normalizeTitleOutput(title string) string {
|
|
title = normalizeShortTextOutput(title)
|
|
if title == "" {
|
|
return ""
|
|
}
|
|
if words := strings.Fields(title); len(words) > titleMaxWords {
|
|
title = strings.Join(words[:titleMaxWords], " ")
|
|
}
|
|
return truncateRunes(title, 80)
|
|
}
|
|
|
|
// contentBlocksToText concatenates the text parts of SDK chat
|
|
// message parts into a single space-separated string.
|
|
func contentBlocksToText(parts []codersdk.ChatMessagePart) string {
|
|
texts := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
if part.Type != codersdk.ChatMessagePartTypeText {
|
|
continue
|
|
}
|
|
text := strings.TrimSpace(part.Text)
|
|
if text == "" {
|
|
continue
|
|
}
|
|
texts = append(texts, text)
|
|
}
|
|
return strings.Join(texts, " ")
|
|
}
|
|
|
|
func truncateRunes(value string, maxLen int) string {
|
|
if maxLen <= 0 {
|
|
return ""
|
|
}
|
|
runes := []rune(value)
|
|
if len(runes) <= maxLen {
|
|
return value
|
|
}
|
|
return string(runes[:maxLen])
|
|
}
|
|
|
|
// Manual title regeneration is user-initiated and can use richer
|
|
// conversation context than the automatic first-message title path
|
|
// above. These helpers keep the manual prompt-building logic private
|
|
// while reusing the shared title-generation utilities in this file.
|
|
type manualTitleTurn struct {
|
|
role string
|
|
text string
|
|
}
|
|
|
|
// extractManualTitleTurns flattens visible user and assistant
|
|
// messages into title turns. pasteText carries resolved pasted-text
|
|
// attachment content (see titlePasteText) so paste-only user messages
|
|
// still produce turns.
|
|
func extractManualTitleTurns(
|
|
messages []database.ChatMessage,
|
|
pasteText map[uuid.UUID]string,
|
|
) []manualTitleTurn {
|
|
turns := make([]manualTitleTurn, 0, len(messages))
|
|
for _, message := range messages {
|
|
if message.Visibility == database.ChatMessageVisibilityModel {
|
|
continue
|
|
}
|
|
|
|
role := ""
|
|
switch message.Role {
|
|
case database.ChatMessageRoleUser:
|
|
role = string(database.ChatMessageRoleUser)
|
|
case database.ChatMessageRoleAssistant:
|
|
role = string(database.ChatMessageRoleAssistant)
|
|
default:
|
|
continue
|
|
}
|
|
|
|
parts, err := chatprompt.ParseContent(message)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
text := chatprompt.TitleText(parts, pasteText)
|
|
if text == "" {
|
|
continue
|
|
}
|
|
|
|
turns = append(turns, manualTitleTurn{
|
|
role: role,
|
|
text: text,
|
|
})
|
|
}
|
|
|
|
return turns
|
|
}
|
|
|
|
func selectManualTitleTurnIndexes(turns []manualTitleTurn) []int {
|
|
firstUserIndex := slices.IndexFunc(turns, func(turn manualTitleTurn) bool {
|
|
return turn.role == string(database.ChatMessageRoleUser)
|
|
})
|
|
if firstUserIndex == -1 {
|
|
return nil
|
|
}
|
|
|
|
windowStart := max(0, len(turns)-recentTurnWindow)
|
|
selected := make([]int, 0, recentTurnWindow+1)
|
|
if firstUserIndex < windowStart {
|
|
selected = append(selected, firstUserIndex)
|
|
}
|
|
for i := windowStart; i < len(turns); i++ {
|
|
selected = append(selected, i)
|
|
}
|
|
|
|
return selected
|
|
}
|
|
|
|
func buildManualTitleContext(
|
|
turns []manualTitleTurn,
|
|
selected []int,
|
|
) (conversationBlock string, latestUserMsg string) {
|
|
userCount := 0
|
|
for _, turn := range turns {
|
|
if turn.role != string(database.ChatMessageRoleUser) {
|
|
continue
|
|
}
|
|
userCount++
|
|
latestUserMsg = turn.text
|
|
}
|
|
|
|
latestUserMsg = truncateRunes(latestUserMsg, maxLatestUserMessageRunes)
|
|
if userCount <= 1 || len(selected) == 0 {
|
|
return "", latestUserMsg
|
|
}
|
|
|
|
lines := make([]string, 0, len(selected)+1)
|
|
for i, idx := range selected {
|
|
if i == 1 {
|
|
if gap := idx - selected[i-1] - 1; gap > 0 {
|
|
lines = append(lines, fmt.Sprintf("[... %d earlier turns omitted ...]", gap))
|
|
}
|
|
}
|
|
lines = append(lines, fmt.Sprintf("[%s]: %s", turns[idx].role, turns[idx].text))
|
|
}
|
|
|
|
conversationBlock = strings.Join(lines, "\n")
|
|
conversationBlock = truncateRunes(conversationBlock, maxConversationContextRunes)
|
|
return conversationBlock, latestUserMsg
|
|
}
|
|
|
|
func renderManualTitlePrompt(
|
|
conversationBlock string,
|
|
firstUserText string,
|
|
latestUserMsg string,
|
|
) string {
|
|
var prompt strings.Builder
|
|
write := func(value string) {
|
|
_, _ = prompt.WriteString(value)
|
|
}
|
|
|
|
write("Write a short title for this AI coding conversation.\n")
|
|
write("Populate the title field with the result.\n\n")
|
|
write("Primary user objective:\n<primary_objective>\n")
|
|
write(firstUserText)
|
|
write("\n</primary_objective>")
|
|
|
|
if conversationBlock != "" {
|
|
write("\n\nConversation sample:\n<conversation_sample>\n")
|
|
write(conversationBlock)
|
|
write("\n</conversation_sample>")
|
|
}
|
|
|
|
if strings.TrimSpace(latestUserMsg) != strings.TrimSpace(truncateRunes(firstUserText, maxLatestUserMessageRunes)) {
|
|
write("\n\nThe user's most recent message:\n<latest_message>\n")
|
|
write(latestUserMsg)
|
|
write("\n</latest_message>\n")
|
|
write("Note: Weight the overall conversation arc more heavily than just the latest message.")
|
|
}
|
|
|
|
write("\n\nRequirements:\n")
|
|
write("- Keep the title to 2-8 words.\n")
|
|
write("- Populate the title field only.\n")
|
|
write("- Do not answer the user or describe the title-writing task.\n")
|
|
write("- Preserve specific identifiers (PR numbers, repo names, file paths, function names, error messages).\n")
|
|
write("- If the conversation is short or vague, stay close to the user's wording.\n")
|
|
write("- Write the title in the same language as the user's messages.\n")
|
|
write("- Sentence case. No quotes, emoji, markdown, or trailing punctuation.\n")
|
|
return prompt.String()
|
|
}
|
|
|
|
// manualTitleGenerationTimeout bounds the model call for manual title
|
|
// generation so a slow or hung provider cannot hold the rename dialog's
|
|
// Generate request indefinitely.
|
|
const manualTitleGenerationTimeout = 30 * time.Second
|
|
|
|
// ErrManualTitleTimedOut marks a manual title failure caused by an expired
|
|
// title-generation deadline, as opposed to an unrelated error that merely
|
|
// wraps context.DeadlineExceeded. The handler maps it to a friendly 504.
|
|
var ErrManualTitleTimedOut = xerrors.New("manual title generation timed out")
|
|
|
|
// markManualTitleTimeout tags err with ErrManualTitleTimedOut when it stems
|
|
// from a deadline so the handler can distinguish a genuine title timeout from
|
|
// other failures.
|
|
func markManualTitleTimeout(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
return errors.Join(ErrManualTitleTimedOut, err)
|
|
}
|
|
|
|
func generateManualTitle(
|
|
ctx context.Context,
|
|
messages []database.ChatMessage,
|
|
pasteText map[uuid.UUID]string,
|
|
fallbackModel fantasy.LanguageModel,
|
|
call fantasy.ObjectCall,
|
|
) (string, error) {
|
|
turns := extractManualTitleTurns(messages, pasteText)
|
|
selected := selectManualTitleTurnIndexes(turns)
|
|
|
|
firstUserIndex := slices.IndexFunc(turns, func(turn manualTitleTurn) bool {
|
|
return turn.role == string(database.ChatMessageRoleUser)
|
|
})
|
|
if firstUserIndex == -1 {
|
|
return "", nil
|
|
}
|
|
firstUserText := truncateRunes(turns[firstUserIndex].text, maxLatestUserMessageRunes)
|
|
|
|
conversationBlock, latestUserMsg := buildManualTitleContext(turns, selected)
|
|
systemPrompt := renderManualTitlePrompt(
|
|
conversationBlock,
|
|
firstUserText,
|
|
latestUserMsg,
|
|
)
|
|
|
|
titleCtx, cancel := context.WithTimeout(ctx, manualTitleGenerationTimeout)
|
|
defer cancel()
|
|
|
|
userInput := strings.TrimSpace(latestUserMsg)
|
|
if userInput == "" {
|
|
userInput = strings.TrimSpace(firstUserText)
|
|
}
|
|
|
|
title, _, err := generateStructuredTitleWithUsage(
|
|
titleCtx,
|
|
fallbackModel,
|
|
call,
|
|
systemPrompt,
|
|
userInput,
|
|
)
|
|
if err != nil {
|
|
// Tag genuine attempt-deadline expiry so the handler can map it to a
|
|
// friendly 504. A provider failure can wrap an unrelated transport
|
|
// deadline while titleCtx is still live; leave that untagged so it
|
|
// keeps its provider-failure surface.
|
|
if errors.Is(err, context.DeadlineExceeded) &&
|
|
errors.Is(titleCtx.Err(), context.DeadlineExceeded) {
|
|
return "", markManualTitleTimeout(err)
|
|
}
|
|
return "", err
|
|
}
|
|
|
|
return title, nil
|
|
}
|
|
|
|
const chatSummaryGenerationPrompt = `Summarize an AI coding chat for a quick-reference popover.
|
|
|
|
Populate the headline field with one sentence of 20 words or fewer, present
|
|
tense, starting with the capability or behavior - "Defines how...",
|
|
"Controls...", "Configures...", "Changes...". Use "Investigates..." if nothing
|
|
was resolved.
|
|
|
|
Populate the bullets field with 2-4 bullets in the same declarative present
|
|
tense, each a single line. Leave the bullets field empty when the headline
|
|
already covers the whole chat, rather than padding it with filler.
|
|
|
|
Preserve identifiers verbatim, wrapped in backticks: PR numbers, repos, file
|
|
paths with line numbers, function and constant names, error messages.
|
|
|
|
Never mention the user or assistant or narrate the exchange. No headings, code
|
|
fences, tables, or nested lists.`
|
|
|
|
const (
|
|
// Bound the transcript so the summary call stays cheap and within context;
|
|
// long chats keep head and tail turns (see renderChatSummaryTranscript).
|
|
summaryTranscriptMaxRunes = 16000
|
|
// Cap a single turn so one long message cannot dominate the budget.
|
|
summaryTranscriptPerMessageMaxRunes = 4000
|
|
// Includes thinking headroom; see titleMaxOutputTokens.
|
|
summaryMaxOutputTokens = 1024
|
|
// Reject pathologically long or verbose summaries.
|
|
summaryMaxRunes = 750
|
|
summaryHeadlineMaxRunes = 200
|
|
summaryHeadlineMaxSentences = 2
|
|
// The prompt asks for a headline of 20 words or fewer; enforce it so a
|
|
// rambling headline cannot pass on rune count alone.
|
|
summaryHeadlineMaxWords = 20
|
|
summaryBulletMaxRunes = 160
|
|
// Upper bound only; requiring bullets would pad trivial chats with
|
|
// filler or reject them, leaving the panel empty.
|
|
summaryMaxBullets = 4
|
|
)
|
|
|
|
type generatedChatSummary struct {
|
|
Headline string `json:"headline" description:"One sentence of 20 words or fewer, present tense, starting with the capability or behavior"`
|
|
Bullets []string `json:"bullets" description:"2-4 declarative present-tense bullets, each one line; empty when the headline already covers the whole chat"`
|
|
}
|
|
|
|
// renderChatSummaryTranscript renders chat history as plain text for summary
|
|
// generation. Plain text avoids provider tool-call pairing rules.
|
|
func renderChatSummaryTranscript(messages []database.ChatMessage) string {
|
|
lines := make([]string, 0, len(messages))
|
|
for _, message := range messages {
|
|
var role string
|
|
switch message.Role {
|
|
case database.ChatMessageRoleUser:
|
|
role = "user"
|
|
case database.ChatMessageRoleAssistant:
|
|
role = "assistant"
|
|
default:
|
|
continue
|
|
}
|
|
|
|
// Keep visible turns plus the compaction summary (model-only but
|
|
// compressed); skip other model-only messages as noise.
|
|
visible := message.Visibility == database.ChatMessageVisibilityBoth ||
|
|
message.Visibility == database.ChatMessageVisibilityUser
|
|
compactionSummary := message.Visibility == database.ChatMessageVisibilityModel &&
|
|
message.Compressed
|
|
if !visible && !compactionSummary {
|
|
continue
|
|
}
|
|
|
|
parts, err := chatprompt.ParseContent(message)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
text := strings.TrimSpace(contentBlocksToText(parts))
|
|
if text == "" {
|
|
continue
|
|
}
|
|
text = truncateRunes(text, summaryTranscriptPerMessageMaxRunes)
|
|
lines = append(lines, fmt.Sprintf("[%s]: %s", role, text))
|
|
}
|
|
return boundTranscriptHeadTail(lines, summaryTranscriptMaxRunes)
|
|
}
|
|
|
|
// boundTranscriptHeadTail joins lines; if over maxRunes it keeps a head and
|
|
// tail slice with an elision marker between, preserving the chat's start and
|
|
// most recent activity.
|
|
func boundTranscriptHeadTail(lines []string, maxRunes int) string {
|
|
if len(lines) == 0 {
|
|
return ""
|
|
}
|
|
total := 0
|
|
for _, line := range lines {
|
|
total += len([]rune(line)) + 1
|
|
}
|
|
if total <= maxRunes {
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
half := maxRunes / 2
|
|
headEnd := 0
|
|
headRunes := 0
|
|
for headEnd < len(lines) {
|
|
n := len([]rune(lines[headEnd])) + 1
|
|
if headEnd > 0 && headRunes+n > half {
|
|
break
|
|
}
|
|
headRunes += n
|
|
headEnd++
|
|
}
|
|
tailStart := len(lines)
|
|
tailRunes := 0
|
|
for tailStart > headEnd {
|
|
n := len([]rune(lines[tailStart-1])) + 1
|
|
if tailRunes+n > half {
|
|
break
|
|
}
|
|
tailRunes += n
|
|
tailStart--
|
|
}
|
|
|
|
var out strings.Builder
|
|
writeLine := func(line string) {
|
|
if out.Len() > 0 {
|
|
_ = out.WriteByte('\n')
|
|
}
|
|
_, _ = out.WriteString(line)
|
|
}
|
|
|
|
for _, line := range lines[:headEnd] {
|
|
writeLine(line)
|
|
}
|
|
if tailStart > headEnd {
|
|
writeLine("[... earlier turns omitted ...]")
|
|
}
|
|
for _, line := range lines[tailStart:] {
|
|
writeLine(line)
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func summaryObjectCall(resolved resolvedModelCall) fantasy.ObjectCall {
|
|
return resolved.newObjectCall(
|
|
"chat_summary",
|
|
"Summarize the whole chat as a one-sentence headline plus up to 4 short bullets.",
|
|
summaryMaxOutputTokens,
|
|
)
|
|
}
|
|
|
|
// generateChatSummary generates a headline-plus-bullets summary from a
|
|
// transcript, serialized to markdown. A blank or invalid result returns an
|
|
// error so callers preserve any existing summary rather than clearing it.
|
|
func generateChatSummary(
|
|
ctx context.Context,
|
|
model fantasy.LanguageModel,
|
|
call fantasy.ObjectCall,
|
|
transcript string,
|
|
) (string, fantasy.Usage, error) {
|
|
transcript = strings.TrimSpace(transcript)
|
|
if transcript == "" {
|
|
return "", fantasy.Usage{}, xerrors.New("chat summary transcript was empty")
|
|
}
|
|
|
|
call.Prompt = quickgenPrompt(chatSummaryGenerationPrompt, transcript)
|
|
var result *fantasy.ObjectResult[generatedChatSummary]
|
|
err := chatretry.Retry(ctx, func(retryCtx context.Context) error {
|
|
var genErr error
|
|
result, genErr = generateObject[generatedChatSummary](retryCtx, model, call)
|
|
return genErr
|
|
}, nil)
|
|
if err != nil {
|
|
var usage fantasy.Usage
|
|
if noObjErr, ok := errors.AsType[*fantasy.NoObjectGeneratedError](err); ok {
|
|
usage = noObjErr.Usage
|
|
}
|
|
return "", usage, xerrors.Errorf("generate chat summary: %w", err)
|
|
}
|
|
|
|
summary := generatedChatSummary{
|
|
Headline: normalizeSummaryField(result.Object.Headline),
|
|
Bullets: normalizeSummaryBullets(result.Object.Bullets),
|
|
}
|
|
if err := validateGeneratedChatSummary(summary); err != nil {
|
|
return "", result.Usage, err
|
|
}
|
|
return formatChatSummaryMarkdown(summary.Headline, summary.Bullets), result.Usage, nil
|
|
}
|
|
|
|
// normalizeSummaryField collapses a field onto one line. Unlike
|
|
// normalizeShortTextOutput it preserves backticks, keeping inline code spans
|
|
// balanced.
|
|
func normalizeSummaryField(text string) string {
|
|
text = strings.Trim(strings.TrimSpace(text), "\"'")
|
|
return strings.Join(strings.Fields(text), " ")
|
|
}
|
|
|
|
func normalizeSummaryBullets(bullets []string) []string {
|
|
normalized := make([]string, 0, len(bullets))
|
|
for _, bullet := range bullets {
|
|
if bullet = normalizeSummaryField(bullet); bullet != "" {
|
|
normalized = append(normalized, bullet)
|
|
}
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
// formatChatSummaryMarkdown renders a headline paragraph plus an optional
|
|
// bullet list. Bullets must already be normalized: no blanks, no newlines.
|
|
func formatChatSummaryMarkdown(headline string, bullets []string) string {
|
|
headline = strings.TrimSpace(headline)
|
|
if len(bullets) == 0 {
|
|
return headline
|
|
}
|
|
return strings.TrimSpace(headline + "\n\n- " + strings.Join(bullets, "\n- "))
|
|
}
|
|
|
|
// validateGeneratedChatSummary checks the structured fields rather than the
|
|
// rendered markdown: bullets omit trailing punctuation, so a sentence count
|
|
// over the serialized string would pass almost anything.
|
|
func validateGeneratedChatSummary(summary generatedChatSummary) error {
|
|
if summary.Headline == "" {
|
|
return xerrors.New("generated chat summary headline was empty")
|
|
}
|
|
if len([]rune(summary.Headline)) > summaryHeadlineMaxRunes {
|
|
return xerrors.Errorf("generated chat summary headline exceeded %d runes", summaryHeadlineMaxRunes)
|
|
}
|
|
if countSentenceTerminators(summary.Headline) > summaryHeadlineMaxSentences {
|
|
return xerrors.Errorf("generated chat summary headline exceeded %d sentences", summaryHeadlineMaxSentences)
|
|
}
|
|
if words := len(strings.Fields(summary.Headline)); words > summaryHeadlineMaxWords {
|
|
return xerrors.Errorf(
|
|
"generated chat summary headline had %d words, want at most %d",
|
|
words, summaryHeadlineMaxWords,
|
|
)
|
|
}
|
|
if len(summary.Bullets) > summaryMaxBullets {
|
|
return xerrors.Errorf(
|
|
"generated chat summary had %d bullets, want at most %d",
|
|
len(summary.Bullets), summaryMaxBullets,
|
|
)
|
|
}
|
|
for _, bullet := range summary.Bullets {
|
|
if len([]rune(bullet)) > summaryBulletMaxRunes {
|
|
return xerrors.Errorf("generated chat summary bullet exceeded %d runes", summaryBulletMaxRunes)
|
|
}
|
|
}
|
|
if rendered := formatChatSummaryMarkdown(summary.Headline, summary.Bullets); len([]rune(rendered)) > summaryMaxRunes {
|
|
return xerrors.Errorf("generated chat summary exceeded %d runes", summaryMaxRunes)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// countSentenceTerminators counts sentence-ending punctuation, but only when
|
|
// followed by whitespace or end-of-text, so periods inside dotted identifiers
|
|
// (pkg.cmd.server, file paths) do not inflate the count.
|
|
func countSentenceTerminators(text string) int {
|
|
runes := []rune(text)
|
|
count := 0
|
|
for i, r := range runes {
|
|
if r != '.' && r != '!' && r != '?' {
|
|
continue
|
|
}
|
|
if i == len(runes)-1 || unicode.IsSpace(runes[i+1]) {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
// markdownLinkRe matches inline links and images so snippet extraction
|
|
// can keep the link text and drop the URL.
|
|
var markdownLinkRe = regexp.MustCompile(`!?\[([^\]]*)\]\([^)]*\)`)
|
|
|
|
func subagentReportSummarySnippet(report string) string {
|
|
paragraph := firstReportParagraph(report)
|
|
if paragraph == "" {
|
|
return ""
|
|
}
|
|
return boundSnippetSentences(
|
|
paragraph,
|
|
subagentReportSummaryMaxSentences,
|
|
subagentReportSummaryMaxRunes,
|
|
)
|
|
}
|
|
|
|
func firstReportParagraph(report string) string {
|
|
var paragraph []string
|
|
inFence := false
|
|
for line := range strings.Lines(report) {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
|
|
if !inFence && len(paragraph) > 0 {
|
|
break
|
|
}
|
|
inFence = !inFence
|
|
continue
|
|
}
|
|
if inFence {
|
|
continue
|
|
}
|
|
if trimmed == "" || isMarkdownStructureLine(trimmed) {
|
|
if len(paragraph) > 0 {
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
content := stripInlineMarkdown(stripLineMarkers(trimmed))
|
|
if content == "" {
|
|
if len(paragraph) > 0 {
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
paragraph = append(paragraph, content)
|
|
}
|
|
return strings.TrimSpace(strings.Join(paragraph, " "))
|
|
}
|
|
|
|
func isMarkdownStructureLine(trimmed string) bool {
|
|
if strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "|") {
|
|
return true
|
|
}
|
|
// Horizontal rules: at least three of the same marker character
|
|
// and nothing else.
|
|
if len(trimmed) >= 3 && strings.Trim(trimmed, "-") == "" {
|
|
return true
|
|
}
|
|
if len(trimmed) >= 3 && strings.Trim(trimmed, "*") == "" {
|
|
return true
|
|
}
|
|
if len(trimmed) >= 3 && strings.Trim(trimmed, "_") == "" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func stripLineMarkers(trimmed string) string {
|
|
for {
|
|
next := trimmed
|
|
next = strings.TrimPrefix(next, ">")
|
|
if rest, ok := trimListMarker(next); ok {
|
|
next = rest
|
|
}
|
|
next = strings.TrimSpace(next)
|
|
if next == trimmed {
|
|
return trimmed
|
|
}
|
|
trimmed = next
|
|
}
|
|
}
|
|
|
|
// trimListMarker strips one leading bullet ("- ", "* ", "+ "), ordered
|
|
// ("1. ", "1) "), or task-list ("[ ] ", "[x] ") marker.
|
|
func trimListMarker(line string) (string, bool) {
|
|
for _, marker := range []string{"- ", "* ", "+ ", "[ ] ", "[x] ", "[X] "} {
|
|
if rest, ok := strings.CutPrefix(line, marker); ok {
|
|
return rest, true
|
|
}
|
|
}
|
|
digits := 0
|
|
for _, r := range line {
|
|
if r < '0' || r > '9' {
|
|
break
|
|
}
|
|
digits++
|
|
}
|
|
if digits > 0 && len(line) > digits+1 &&
|
|
(line[digits] == '.' || line[digits] == ')') && line[digits+1] == ' ' {
|
|
return line[digits+2:], true
|
|
}
|
|
return line, false
|
|
}
|
|
|
|
func stripInlineMarkdown(text string) string {
|
|
text = markdownLinkRe.ReplaceAllString(text, "$1")
|
|
replacer := strings.NewReplacer("**", "", "__", "", "~~", "", "`", "")
|
|
return strings.TrimSpace(replacer.Replace(text))
|
|
}
|
|
|
|
func boundSnippetSentences(text string, maxSentences, maxRunes int) string {
|
|
runes := []rune(text)
|
|
sentences := 0
|
|
lastEnd := 0
|
|
for i, r := range runes {
|
|
if r != '.' && r != '!' && r != '?' {
|
|
continue
|
|
}
|
|
if i != len(runes)-1 && !unicode.IsSpace(runes[i+1]) {
|
|
continue
|
|
}
|
|
if i+1 > maxRunes {
|
|
break
|
|
}
|
|
lastEnd = i + 1
|
|
sentences++
|
|
if sentences >= maxSentences {
|
|
break
|
|
}
|
|
}
|
|
if lastEnd > 0 {
|
|
return strings.TrimSpace(string(runes[:lastEnd]))
|
|
}
|
|
if len(runes) <= maxRunes {
|
|
return text
|
|
}
|
|
return strings.TrimSpace(string(runes[:maxRunes-1])) + "…"
|
|
}
|
|
|
|
const turnStatusLabelPrompt = "You write compact chat status labels for a sidebar or push notification. " +
|
|
"Given a chat title, current chat state, and the agent's latest message, populate the label field with a 2-5 word status label. " +
|
|
"Describe the chat's current state, not the agent. " +
|
|
"Good examples: Finished unit tests, Submitted PR, Still working on API, Waiting for user input. " +
|
|
"Do not start with Agent, I, We, It, The agent, or The chat. " +
|
|
"Avoid phrases like Agent asked, Agent identified, Agent found, or Agent explained. " +
|
|
"Prefer short action or state phrases such as Finished, Submitted, Fixed, Testing, Still working, or Waiting for. " +
|
|
"No quotes, emoji, markdown, or trailing punctuation."
|
|
|
|
// Includes thinking headroom; see titleMaxOutputTokens.
|
|
const turnStatusLabelMaxOutputTokens = int64(1024)
|
|
|
|
func turnStatusLabelObjectCall(resolved resolvedModelCall) fantasy.ObjectCall {
|
|
return resolved.newObjectCall("propose_turn_status_label", "Propose a compact chat status label.", turnStatusLabelMaxOutputTokens)
|
|
}
|
|
|
|
// generateTurnStatusLabel returns an empty string if generation fails.
|
|
func generateTurnStatusLabel(
|
|
ctx context.Context,
|
|
chat database.Chat,
|
|
status database.ChatStatus,
|
|
assistantText string,
|
|
resolved resolvedModelCall,
|
|
logger slog.Logger,
|
|
debugSvc *chatdebug.Service,
|
|
triggerMessageID int64,
|
|
historyTipMessageID int64,
|
|
) string {
|
|
debugEnabled := debugSvc != nil && debugSvc.IsEnabled(ctx, chat.ID, chat.OwnerID)
|
|
|
|
labelCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
|
|
assistantText = truncateRunes(assistantText, maxConversationContextRunes)
|
|
input := "Current chat state: " + turnStatusLabelStateContext(status) +
|
|
"\nChat title: " + chat.Title +
|
|
"\n\nAgent's latest message:\n" + assistantText
|
|
|
|
candidate := shortTextCandidate{
|
|
provider: resolved.resolvedProvider,
|
|
model: resolved.resolvedModel,
|
|
resolved: resolved,
|
|
}
|
|
|
|
statusSeedSummary := chatdebug.SeedSummary("Turn status label")
|
|
|
|
candidateCtx := labelCtx
|
|
finishDebugRun := func(error) {}
|
|
if debugEnabled {
|
|
candidateCtx, finishDebugRun = prepareQuickgenDebugCandidate(
|
|
labelCtx,
|
|
chat,
|
|
debugSvc,
|
|
candidate,
|
|
chatdebug.KindQuickgen,
|
|
triggerMessageID,
|
|
historyTipMessageID,
|
|
statusSeedSummary,
|
|
logger,
|
|
)
|
|
}
|
|
|
|
generatedLabel, err := generateStructuredTurnStatusLabel(
|
|
candidateCtx,
|
|
candidate.resolved.model.LanguageModel(),
|
|
turnStatusLabelObjectCall(resolved),
|
|
turnStatusLabelPrompt,
|
|
input,
|
|
)
|
|
finishDebugRun(err)
|
|
if err != nil {
|
|
logger.Warn(ctx, "turn status label model candidate failed",
|
|
slog.F("provider", resolved.resolvedProvider),
|
|
slog.F("model", resolved.resolvedModel),
|
|
slog.Error(err),
|
|
)
|
|
return ""
|
|
}
|
|
return generatedLabel
|
|
}
|
|
|
|
func generateStructuredTurnStatusLabel(
|
|
ctx context.Context,
|
|
model fantasy.LanguageModel,
|
|
call fantasy.ObjectCall,
|
|
systemPrompt string,
|
|
userInput string,
|
|
) (string, error) {
|
|
userInput = strings.TrimSpace(userInput)
|
|
if userInput == "" {
|
|
return "", xerrors.New("turn status label input was empty")
|
|
}
|
|
|
|
call.Prompt = quickgenPrompt(systemPrompt, userInput)
|
|
result, err := generateQuickgenObject[generatedTurnStatusLabel](ctx, model, call)
|
|
if err != nil {
|
|
return "", xerrors.Errorf("generate structured turn status label: %w", err)
|
|
}
|
|
|
|
label, ok := normalizeTurnStatusLabel(result.Object.Label)
|
|
if !ok {
|
|
return "", xerrors.New("generated turn status label was invalid")
|
|
}
|
|
return label, nil
|
|
}
|
|
|
|
func turnStatusLabelStateContext(status database.ChatStatus) string {
|
|
switch status {
|
|
case database.ChatStatusWaiting:
|
|
return "The turn finished and the chat is idle."
|
|
case database.ChatStatusRequiresAction:
|
|
return "The chat is waiting for user input or action."
|
|
case database.ChatStatusError:
|
|
return "The chat ended with an error."
|
|
default:
|
|
return "The chat state is unknown."
|
|
}
|
|
}
|
|
|
|
func fallbackTurnStatusLabel(status database.ChatStatus) string {
|
|
switch status {
|
|
case database.ChatStatusWaiting:
|
|
return "Finished latest turn"
|
|
case database.ChatStatusRequiresAction:
|
|
return "Waiting for user input"
|
|
case database.ChatStatusError:
|
|
return "Hit an error"
|
|
default:
|
|
return "Updated chat status"
|
|
}
|
|
}
|
|
|
|
func normalizeTurnStatusLabel(text string) (string, bool) {
|
|
text = strings.TrimSpace(text)
|
|
if text == "" {
|
|
return "", false
|
|
}
|
|
|
|
text = strings.Trim(text, "\"'`")
|
|
text = strings.TrimSpace(text)
|
|
if text == "" || strings.ContainsAny(text, "\r\n") {
|
|
return "", false
|
|
}
|
|
text = strings.TrimRight(text, ".!?")
|
|
text = strings.Join(strings.Fields(text), " ")
|
|
if text == "" || hasSentenceBoundary(text) {
|
|
return "", false
|
|
}
|
|
|
|
words := strings.Fields(text)
|
|
if len(words) < 2 || len(words) > 5 {
|
|
return "", false
|
|
}
|
|
|
|
lower := strings.ToLower(text)
|
|
if hasDisallowedTurnStatusLabelSubject(lower) {
|
|
return "", false
|
|
}
|
|
|
|
disallowedPhrases := []string{
|
|
"agent asked",
|
|
"agent identified",
|
|
"agent found",
|
|
"agent explained",
|
|
}
|
|
for _, phrase := range disallowedPhrases {
|
|
if strings.Contains(lower, phrase) {
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
return text, true
|
|
}
|
|
|
|
func hasDisallowedTurnStatusLabelSubject(text string) bool {
|
|
subject := leadingLetters(text)
|
|
switch subject {
|
|
case "agent", "i", "it", "the", "we":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func leadingLetters(text string) string {
|
|
for i, r := range text {
|
|
if r < 'a' || r > 'z' {
|
|
return text[:i]
|
|
}
|
|
}
|
|
return text
|
|
}
|
|
|
|
func hasSentenceBoundary(text string) bool {
|
|
for i, r := range text {
|
|
switch r {
|
|
case '.', '!', '?':
|
|
if i+1 < len(text) && text[i+1] == ' ' {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|