From 59cec5be65691948c7187e41f100c2f4bb3fd445 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Fri, 27 Feb 2026 14:06:22 -0500 Subject: [PATCH] feat: add pagination and popularity sorting to chattool list_templates (#22398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- coderd/chatd/chattool/listtemplates.go | 64 ++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/coderd/chatd/chattool/listtemplates.go b/coderd/chatd/chattool/listtemplates.go index cfc077da41..f11ef5d801 100644 --- a/coderd/chatd/chattool/listtemplates.go +++ b/coderd/chatd/chattool/listtemplates.go @@ -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 }, )