Generate produces a title for a session based on the provided user messages. It performs one-shot LLM calls directly via the provider's CreateChatCompletionStream, avoiding the overhead of spinning up a nested runtime, and falls back to the next model on failure. Returns an empty string if no models
(ctx context.Context, sessionID string, userMessages []string)
| 63 | // runtime, and falls back to the next model on failure. |
| 64 | // Returns an empty string if no models or messages are configured. |
| 65 | func (g *Generator) Generate(ctx context.Context, sessionID string, userMessages []string) (title string, err error) { |
| 66 | if g == nil || len(g.models) == 0 || len(userMessages) == 0 { |
| 67 | return "", nil |
| 68 | } |
| 69 | |
| 70 | // Title generation runs outside the run loop, so the session ID |
| 71 | // is not yet on ctx. Stamp it here so the gateway-bound LLM calls |
| 72 | // below carry `X-Cagent-Session-Id` and remain attributable to |
| 73 | // the originating session. |
| 74 | ctx = httpclient.ContextWithSessionID(ctx, sessionID) |
| 75 | |
| 76 | // Wrap the whole title-generation in a span so the boundary is |
| 77 | // visible on the session timeline. The inner per-attempt LLM |
| 78 | // calls each get their own `chat {model}` CLIENT child span via |
| 79 | // the provider decorator. |
| 80 | ctx, span := otel.Tracer("github.com/docker/docker-agent/pkg/sessiontitle").Start( |
| 81 | ctx, |
| 82 | "sessiontitle.generate", |
| 83 | trace.WithSpanKind(trace.SpanKindInternal), |
| 84 | trace.WithAttributes( |
| 85 | attribute.String(genai.AttrConversationID, sessionID), |
| 86 | attribute.Int("cagent.sessiontitle.candidate_count", len(g.models)), |
| 87 | ), |
| 88 | ) |
| 89 | defer func() { |
| 90 | if err != nil { |
| 91 | span.RecordError(err) |
| 92 | span.SetStatus(codes.Error, err.Error()) |
| 93 | } |
| 94 | span.End() |
| 95 | }() |
| 96 | |
| 97 | // Apply timeout to prevent hanging on slow or unresponsive models. |
| 98 | ctx, cancel := context.WithTimeout(ctx, titleGenerationTimeout) |
| 99 | defer cancel() |
| 100 | |
| 101 | slog.DebugContext(ctx, "Generating title for session", "session_id", sessionID, "message_count", len(userMessages)) |
| 102 | |
| 103 | messages := buildPrompt(userMessages) |
| 104 | |
| 105 | var errs []error |
| 106 | for idx, baseModel := range g.models { |
| 107 | // Assign to the named-return `err` so a context cancellation |
| 108 | // is observed by the deferred span closure as a recorded |
| 109 | // error rather than silently slipping through. |
| 110 | if err = ctx.Err(); err != nil { //nolint:gocritic // assigns to named return `err` for deferred span observability |
| 111 | return "", err |
| 112 | } |
| 113 | |
| 114 | title, err := generateOnce(ctx, baseModel, messages) |
| 115 | if err == nil { |
| 116 | slog.DebugContext(ctx, "Generated session title", "session_id", sessionID, "title", title, "model", baseModel.ID()) |
| 117 | return title, nil |
| 118 | } |
| 119 | |
| 120 | errs = append(errs, err) |
| 121 | // Per-attempt failures are logged at Debug because we still have |
| 122 | // fallbacks; the final error joins every attempt so callers see |