Files
coder/coderd/x/chatd/quickgen.go
T
Ethan 807878c1ab fix(coderd): return database errors at chat create and subagent spawn (#30449)
Relates to CODAGT-1330

chatd stores the deployment and organization system prompts in a chat
when it's created. If either read failed, we'd log it and use the
built-in default, or skip the organization prompt, so the chat ignored
the admin's instructions for its whole life. The subagent model override
had the same problem, and the compaction and advisor overrides quietly
fell back to the chat model for that turn.

To fix, we'll just return the database error instead. Overrides that
can't be used, e.g. the model was deleted or the user has no API key for
its provider, still fall back like they do today.
2026-10-12 09:53:44 +11:00

1671 lines
51 KiB
Go

package chatd
import (
"context"
"database/sql"
"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,
maxRetries int,
) (*fantasy.ObjectResult[T], error) {
call.Temperature = new(quickgenTemperature)
var result *fantasy.ObjectResult[T]
err := chatretry.Retry(ctx, maxRetries, 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,
failIfUnavailable: true,
failIfNoProviderKey: true,
})
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
}
p.GenerateChatTitleForMessagesAsync(ctx, chat, messages)
}
// GenerateChatTitleForMessagesAsync is GenerateChatTitleAsync for
// callers that already hold a message snapshot taken before any worker
// could reply. The first-message endpoint publishes the ownership hint
// during the send, before it can schedule title generation, so a fresh
// history read there may already contain the assistant reply and would
// wrongly disqualify the first-user-turn eligibility check.
func (p *Server) GenerateChatTitleForMessagesAsync(ctx context.Context, chat database.Chat, messages []database.ChatMessage) {
logger := p.logger.With(
slog.F("chat_id", chat.ID),
slog.F("owner_id", chat.OwnerID),
)
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.inflightChatContext(ctx, chat)
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 source is fallback) 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), p.chatLimits.MaxGenerationRetries, 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
}
updatedChat, err := p.db.UpdateChatTitleByID(ctx, database.UpdateChatTitleByIDParams{
ID: chat.ID,
Title: title,
TitleSource: database.ChatTitleSourceGenerated,
})
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
logger.Warn(ctx, "failed to update generated chat title",
slog.F("chat_id", chat.ID),
slog.Error(err),
)
}
return
}
generatedTitle.Store(title)
p.publishChatPubsubEvent(updatedChat, 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,
maxRetries int,
input string,
) (string, error) {
title, err := generateStructuredTitle(ctx, model, call, maxRetries, titleGenerationPrompt, input)
if err != nil {
return "", err
}
return title, nil
}
func generateStructuredTitle(
ctx context.Context,
model fantasy.LanguageModel,
call fantasy.ObjectCall,
maxRetries int,
systemPrompt string,
userInput string,
) (string, error) {
title, _, err := generateStructuredTitleWithUsage(
ctx,
model,
call,
maxRetries,
systemPrompt,
userInput,
)
if err != nil {
return "", err
}
return title, nil
}
func generateStructuredTitleWithUsage(
ctx context.Context,
model fantasy.LanguageModel,
call fantasy.ObjectCall,
maxRetries int,
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, maxRetries)
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 source is not fallback. 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
}
if chat.TitleSource != database.ChatTitleSourceFallback {
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,
maxRetries int,
) (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,
maxRetries,
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,
maxRetries int,
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, maxRetries, 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,
maxRetries int,
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),
maxRetries,
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,
maxRetries int,
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, maxRetries)
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
}