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.
This commit is contained in:
Kyle Carberry
2026-02-27 14:06:22 -05:00
committed by GitHub
parent 7043e773cf
commit 59cec5be65
+59 -5
View File
@@ -3,6 +3,7 @@ package chattool
import (
"context"
"database/sql"
"sort"
"strings"
"charm.land/fantasy"
@@ -15,6 +16,8 @@ import (
"github.com/coder/coder/v2/coderd/rbac"
)
const listTemplatesPageSize = 10
// ListTemplatesOptions configures the list_templates tool.
type ListTemplatesOptions struct {
DB database.Store
@@ -23,16 +26,21 @@ type ListTemplatesOptions struct {
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.",
"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
@@ -60,8 +68,48 @@ func ListTemplates(options ListTemplatesOptions) fantasy.AgentTool {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
items := make([]map[string]any, 0, len(templates))
for _, t := range templates {
// 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,
@@ -72,12 +120,18 @@ func ListTemplates(options ListTemplatesOptions) fantasy.AgentTool {
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),
"templates": items,
"count": len(items),
"page": page,
"total_pages": totalPages,
"total_count": totalCount,
}), nil
},
)