Files
coder/coderd/chatd/chattool/listtemplates.go
T
Kyle Carberry 59cec5be65 feat: add pagination and popularity sorting to chattool list_templates (#22398)
## Summary

The `chattool` `list_templates` tool previously returned all templates
in a single response with no popularity signal. On deployments with many
templates (e.g. 71 on dogfood), this wastes tokens and makes it hard for
the AI to pick the right template for broad user questions.

## Changes

Single file: `coderd/chatd/chattool/listtemplates.go`

- **`page` parameter** — optional, 1-indexed, 10 results per page
- **Popularity sort** — queries
`GetWorkspaceUniqueOwnerCountByTemplateIDs` to get active developer
counts, then sorts descending (most popular first). The DB query returns
templates alphabetically, so this explicit sort is needed.
- **`active_developers`** — included on each template item so the agent
can see the signal
- **Pagination metadata** — `page`, `total_pages`, `total_count` in the
response so the agent knows there are more results
- **Updated tool description** — tells the agent that results are
ordered by popularity and paginated

## Frontend

No frontend changes needed. The renderer already reads `rec.templates`
and `rec.count` from the response — the new fields (`page`,
`total_pages`, `total_count`) are additive and safely ignored.
2026-02-27 14:06:22 -05:00

149 lines
4.3 KiB
Go

package chattool
import (
"context"
"database/sql"
"sort"
"strings"
"charm.land/fantasy"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac"
)
const listTemplatesPageSize = 10
// ListTemplatesOptions configures the list_templates tool.
type ListTemplatesOptions struct {
DB database.Store
OwnerID uuid.UUID
}
type listTemplatesArgs struct {
Query string `json:"query,omitempty"`
Page int `json:"page,omitempty"`
}
// ListTemplates returns a tool that lists available workspace templates.
// The agent uses this to discover templates before creating a workspace.
// Results are ordered by number of active developers (most popular first)
// and paginated at 10 per page.
func ListTemplates(options ListTemplatesOptions) fantasy.AgentTool {
return fantasy.NewAgentTool(
"list_templates",
"List available workspace templates. Optionally filter by a "+
"search query matching template name or description. "+
"Use this to find a template before creating a workspace. "+
"Results are ordered by number of active developers (most popular first). "+
"Returns 10 per page. Use the page parameter to paginate through results.",
func(ctx context.Context, args listTemplatesArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
if options.DB == nil {
return fantasy.NewTextErrorResponse("database is not configured"), nil
}
ctx, err := asOwner(ctx, options.DB, options.OwnerID)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
filterParams := database.GetTemplatesWithFilterParams{
Deleted: false,
Deprecated: sql.NullBool{
Bool: false,
Valid: true,
},
}
query := strings.TrimSpace(args.Query)
if query != "" {
filterParams.FuzzyName = query
}
templates, err := options.DB.GetTemplatesWithFilter(ctx, filterParams)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
// Look up active developer counts so we can sort by popularity.
templateIDs := make([]uuid.UUID, len(templates))
for i, t := range templates {
templateIDs[i] = t.ID
}
ownerCounts := make(map[uuid.UUID]int64)
if len(templateIDs) > 0 {
rows, countErr := options.DB.GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx, templateIDs)
if countErr == nil {
for _, row := range rows {
ownerCounts[row.TemplateID] = row.UniqueOwnersSum
}
}
}
// Sort by active developer count descending.
sort.SliceStable(templates, func(i, j int) bool {
return ownerCounts[templates[i].ID] > ownerCounts[templates[j].ID]
})
// Paginate.
page := args.Page
if page < 1 {
page = 1
}
totalCount := len(templates)
totalPages := (totalCount + listTemplatesPageSize - 1) / listTemplatesPageSize
if totalPages == 0 {
totalPages = 1
}
start := (page - 1) * listTemplatesPageSize
end := start + listTemplatesPageSize
if start > totalCount {
start = totalCount
}
if end > totalCount {
end = totalCount
}
pageTemplates := templates[start:end]
items := make([]map[string]any, 0, len(pageTemplates))
for _, t := range pageTemplates {
item := map[string]any{
"id": t.ID.String(),
"name": t.Name,
}
if display := strings.TrimSpace(t.DisplayName); display != "" {
item["display_name"] = display
}
if desc := strings.TrimSpace(t.Description); desc != "" {
item["description"] = truncateRunes(desc, 200)
}
if count, ok := ownerCounts[t.ID]; ok && count > 0 {
item["active_developers"] = count
}
items = append(items, item)
}
return toolResponse(map[string]any{
"templates": items,
"count": len(items),
"page": page,
"total_pages": totalPages,
"total_count": totalCount,
}), nil
},
)
}
// asOwner sets up a dbauthz context for the given owner so that
// subsequent database calls are scoped to what that user can access.
func asOwner(ctx context.Context, db database.Store, ownerID uuid.UUID) (context.Context, error) {
actor, _, err := httpmw.UserRBACSubject(ctx, db, ownerID, rbac.ScopeAll)
if err != nil {
return ctx, xerrors.Errorf("load user authorization: %w", err)
}
return dbauthz.As(ctx, actor), nil
}