feat: rank chat workspace templates (#25037)

closes CODAGT-203

## Summary

`list_templates` now returns a ranked shortlist with a recommendation,
so the chat agent can pick the right template the way a colleague would:
prefer what matches the request, what the user already uses, and what
the rest of the organization uses. Instead of teaching the model an enum
protocol in prompts, every result carries a fixed `next_step`
instruction telling the agent what to do.

## How list_templates works

1. **Fetch**: active, non-deprecated templates in the chat's
organization, filtered by the admin template allowlist, authorized as
the chat owner (no system escalation).
2. **Query relevance** (optional `query` argument): each template
receives the highest tier any of its fields matches, and a higher tier
always outranks a lower one regardless of usage:

   | Tier | Match |
   |------|-------|
   | 4 | name or display name equals the query |
   | 3 | name or display name starts with the query |
   | 2 | name or display name contains the query |
| 1 | description contains the query (checked only when no name field
matched) |
   | 0 | no match; the template is excluded |

Matching is case-insensitive and ignores spaces/hyphens/underscores
(`python gpu` matches `python-gpu`).
3. **Usage signals**: a new `GetTemplateRankingSignalsByOwnerID` query
returns, per template, the owner's active and recently-deleted workspace
counts within a 60-day window, the last in-window usage, and the count
of distinct developers with an active workspace (unclaimed prebuilds
excluded).
4. **Affinity score** (computed in Go, per template, from that
template's signals only):

   ```text
affinity = 10 x (active + 0.5 x deleted) x 0.5^(days_since_last_use /
14)
            + ln(1 + active_developers)
   ```

`active`/`deleted` are the owner's in-window workspace counts,
`days_since_last_use` is measured from the most recent in-window usage
(the personal term is zero without in-window usage), and
`active_developers` is the org-wide count. Personal usage carries 10x
the weight of org popularity; the confidence floor is the score of two
active developers (`ln 3`) and the required lead over the runner-up is
`ln 3 - ln 2`.
5. **Rank**: query tier first (when a query is present), then affinity
score, then name/ID for determinism. Results paginate 10 per page with
`next_page` present only when more exist.

## Recommendation contract

The result tells the agent what to do next instead of describing
confidence levels:

- `recommended_template_id` is present only when the top template is a
clear winner: the only available template, a decisive query match, or an
affinity score that clears a floor and leads the runner-up by a derived
margin.
- `next_step` is always present and is one of four fixed sentences: use
the recommendation, ask the user to choose, retry a query that matched
nothing, or report that no templates are available.

Per-template items carry raw evidence (`active_developers`,
`your_workspace_count`, `last_used_by_you`) rather than derived labels.
When signals fail to load, the tool logs and degrades to asking the user
unless the query alone is decisive.

Prompts and the `create_workspace`/`read_template` descriptions
reference the field through the `chattool.NextStepField` constant, so
the instruction lives in one place and cannot drift. `create_workspace`
remains idempotent and allowlist-enforced.

## Authorization

The signals query runs with the chat owner's permissions: reading the
owner's own workspaces plus a template-metadata read for the cross-user
popularity count. dbauthz rejects the call if any requested template is
not readable by the owner (covered by allow and deny method tests).

## Docs

Adds `docs/ai-coder/agents/tools/` explaining how agent tool calls work,
with `list_templates` ranking and the `next_step` contract as the first
documented tools.
This commit is contained in:
Jaayden Halko
2026-06-18 06:41:47 +01:00
committed by GitHub
parent ea1379d42c
commit bc44cdda75
21 changed files with 1975 additions and 94 deletions
+39
View File
@@ -4592,6 +4592,45 @@ func (q *querier) GetTemplatePresetsWithPrebuilds(ctx context.Context, templateI
return q.db.GetTemplatePresetsWithPrebuilds(ctx, templateID)
}
func (q *querier) GetTemplateRankingSignalsByOwnerID(ctx context.Context, arg database.GetTemplateRankingSignalsByOwnerIDParams) ([]database.GetTemplateRankingSignalsByOwnerIDRow, error) {
// The personal signal reads only the owner's own workspaces.
workspaceObj := rbac.ResourceWorkspace.WithOwner(arg.OwnerID.String())
if arg.OrganizationID != uuid.Nil {
workspaceObj = workspaceObj.InOrg(arg.OrganizationID)
} else {
workspaceObj = workspaceObj.AnyOrganization()
}
if err := q.authorizeContext(ctx, policy.ActionRead, workspaceObj); err != nil {
return nil, err
}
// The cross-user popularity count is template metadata, not workspace
// reads, so it only requires read access to every requested template.
if len(arg.TemplateIDs) > 0 {
prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceTemplate.Type)
if err != nil {
return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err)
}
authorizedTemplates, err := q.db.GetAuthorizedTemplates(ctx, database.GetTemplatesWithFilterParams{
Deleted: false,
OrganizationID: arg.OrganizationID,
IDs: arg.TemplateIDs,
}, prep)
if err != nil {
return nil, err
}
authorizedIDs := make(map[uuid.UUID]struct{}, len(authorizedTemplates))
for _, template := range authorizedTemplates {
authorizedIDs[template.ID] = struct{}{}
}
for _, templateID := range arg.TemplateIDs {
if _, ok := authorizedIDs[templateID]; !ok {
return nil, NotAuthorizedError{Err: xerrors.Errorf("not authorized to read template %s", templateID)}
}
}
}
return q.db.GetTemplateRankingSignalsByOwnerID(ctx, arg)
}
func (q *querier) GetTemplateUsageStats(ctx context.Context, arg database.GetTemplateUsageStatsParams) ([]database.TemplateUsageStat, error) {
if err := q.authorizeTemplateInsights(ctx, arg.TemplateIDs); err != nil {
return nil, err
+43
View File
@@ -3586,6 +3586,49 @@ func (s *MethodTestSuite) TestWorkspace() {
// No asserts here because SQLFilter.
check.Args(ws.OwnerID, emptyPreparedAuthorized{}).Asserts()
}))
s.Run("GetTemplateRankingSignalsByOwnerID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
arg := database.GetTemplateRankingSignalsByOwnerIDParams{
OwnerID: uuid.New(),
OrganizationID: uuid.New(),
TemplateIDs: []uuid.UUID{uuid.New()},
}
dbm.EXPECT().GetAuthorizedTemplates(gomock.Any(), database.GetTemplatesWithFilterParams{
Deleted: false,
OrganizationID: arg.OrganizationID,
IDs: arg.TemplateIDs,
}, gomock.Any()).Return([]database.Template{{ID: arg.TemplateIDs[0]}}, nil).AnyTimes()
dbm.EXPECT().GetTemplateRankingSignalsByOwnerID(gomock.Any(), arg).Return([]database.GetTemplateRankingSignalsByOwnerIDRow{}, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceWorkspace.WithOwner(arg.OwnerID.String()).InOrg(arg.OrganizationID), policy.ActionRead)
}))
s.Run("GetTemplateRankingSignalsByOwnerID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
arg := database.GetTemplateRankingSignalsByOwnerIDParams{
OwnerID: uuid.New(),
TemplateIDs: []uuid.UUID{uuid.New()},
}
dbm.EXPECT().GetAuthorizedTemplates(gomock.Any(), database.GetTemplatesWithFilterParams{
Deleted: false,
IDs: arg.TemplateIDs,
}, gomock.Any()).Return([]database.Template{{ID: arg.TemplateIDs[0]}}, nil).AnyTimes()
dbm.EXPECT().GetTemplateRankingSignalsByOwnerID(gomock.Any(), arg).Return([]database.GetTemplateRankingSignalsByOwnerIDRow{}, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceWorkspace.WithOwner(arg.OwnerID.String()).AnyOrganization(), policy.ActionRead)
}))
s.Run("GetTemplateRankingSignalsByOwnerID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
// Deny path: an unauthorized template ID rejects the call before the
// query runs (no query expectation is registered).
arg := database.GetTemplateRankingSignalsByOwnerIDParams{
OwnerID: uuid.New(),
OrganizationID: uuid.New(),
TemplateIDs: []uuid.UUID{uuid.New(), uuid.New()},
}
dbm.EXPECT().GetAuthorizedTemplates(gomock.Any(), database.GetTemplatesWithFilterParams{
Deleted: false,
OrganizationID: arg.OrganizationID,
IDs: arg.TemplateIDs,
}, gomock.Any()).Return([]database.Template{{ID: arg.TemplateIDs[0]}}, nil).AnyTimes()
check.Args(arg).
Asserts(rbac.ResourceWorkspace.WithOwner(arg.OwnerID.String()).InOrg(arg.OrganizationID), policy.ActionRead).
Errors(dbauthz.NotAuthorizedError{Err: xerrors.Errorf("not authorized to read template %s", arg.TemplateIDs[1])})
}))
s.Run("GetWorkspaceACLByID", s.Mocked(func(dbM *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
ws := testutil.Fake(s.T(), faker, database.Workspace{})
dbM.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes()
+4
View File
@@ -192,6 +192,10 @@ func (s *MethodTestSuite) SubtestWithDB(db database.Store, testCaseF func(db dat
testName := s.T().Name()
names := strings.Split(testName, "/")
methodName := names[len(names)-1]
// Repeated subtests get "#NN" suffixes; count them under the base method.
if baseMethodName, _, ok := strings.Cut(methodName, "#"); ok {
methodName = baseMethodName
}
s.methodAccounting[methodName]++
fakeAuthorizer := &coderdtest.FakeAuthorizer{}
+8
View File
@@ -2978,6 +2978,14 @@ func (m queryMetricsStore) GetTemplatePresetsWithPrebuilds(ctx context.Context,
return r0, r1
}
func (m queryMetricsStore) GetTemplateRankingSignalsByOwnerID(ctx context.Context, arg database.GetTemplateRankingSignalsByOwnerIDParams) ([]database.GetTemplateRankingSignalsByOwnerIDRow, error) {
start := time.Now()
r0, r1 := m.s.GetTemplateRankingSignalsByOwnerID(ctx, arg)
m.queryLatencies.WithLabelValues("GetTemplateRankingSignalsByOwnerID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetTemplateRankingSignalsByOwnerID").Inc()
return r0, r1
}
func (m queryMetricsStore) GetTemplateUsageStats(ctx context.Context, arg database.GetTemplateUsageStatsParams) ([]database.TemplateUsageStat, error) {
start := time.Now()
r0, r1 := m.s.GetTemplateUsageStats(ctx, arg)
+15
View File
@@ -5547,6 +5547,21 @@ func (mr *MockStoreMockRecorder) GetTemplatePresetsWithPrebuilds(ctx, templateID
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplatePresetsWithPrebuilds", reflect.TypeOf((*MockStore)(nil).GetTemplatePresetsWithPrebuilds), ctx, templateID)
}
// GetTemplateRankingSignalsByOwnerID mocks base method.
func (m *MockStore) GetTemplateRankingSignalsByOwnerID(ctx context.Context, arg database.GetTemplateRankingSignalsByOwnerIDParams) ([]database.GetTemplateRankingSignalsByOwnerIDRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTemplateRankingSignalsByOwnerID", ctx, arg)
ret0, _ := ret[0].([]database.GetTemplateRankingSignalsByOwnerIDRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetTemplateRankingSignalsByOwnerID indicates an expected call of GetTemplateRankingSignalsByOwnerID.
func (mr *MockStoreMockRecorder) GetTemplateRankingSignalsByOwnerID(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateRankingSignalsByOwnerID", reflect.TypeOf((*MockStore)(nil).GetTemplateRankingSignalsByOwnerID), ctx, arg)
}
// GetTemplateUsageStats mocks base method.
func (m *MockStore) GetTemplateUsageStats(ctx context.Context, arg database.GetTemplateUsageStatsParams) ([]database.TemplateUsageStat, error) {
m.ctrl.T.Helper()
+6
View File
@@ -776,6 +776,12 @@ type sqlcQuerier interface {
// It also returns the number of desired instances for each preset.
// If template_id is specified, only template versions associated with that template will be returned.
GetTemplatePresetsWithPrebuilds(ctx context.Context, templateID uuid.NullUUID) ([]GetTemplatePresetsWithPrebuildsRow, error)
// GetTemplateRankingSignalsByOwnerID returns raw template-ranking signals for
// one owner: in-window active and recently-deleted workspace counts, the last
// in-window usage, and distinct active developers per template. The affinity
// score is computed in Go (see listtemplates.go) so the ranking policy and
// its confidence thresholds live in one place.
GetTemplateRankingSignalsByOwnerID(ctx context.Context, arg GetTemplateRankingSignalsByOwnerIDParams) ([]GetTemplateRankingSignalsByOwnerIDRow, error)
GetTemplateUsageStats(ctx context.Context, arg GetTemplateUsageStatsParams) ([]TemplateUsageStat, error)
GetTemplateVersionByID(ctx context.Context, id uuid.UUID) (TemplateVersion, error)
GetTemplateVersionByJobID(ctx context.Context, jobID uuid.UUID) (TemplateVersion, error)
+111
View File
@@ -36996,6 +36996,117 @@ func (q *sqlQuerier) GetRegularWorkspaceCreateMetrics(ctx context.Context) ([]Ge
return items, nil
}
const getTemplateRankingSignalsByOwnerID = `-- name: GetTemplateRankingSignalsByOwnerID :many
WITH org_usage AS (
-- Distinct developers with a non-deleted workspace; the prebuilds system
-- user is excluded so unclaimed prebuilds do not inflate popularity.
SELECT
w.template_id,
COUNT(DISTINCT w.owner_id) AS org_devs
FROM
workspaces w
WHERE
w.template_id = ANY($1 :: uuid[])
AND NOT w.deleted
AND w.owner_id != $2 :: uuid
AND CASE
WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000' :: uuid THEN
w.organization_id = $3
ELSE true
END
GROUP BY
w.template_id
),
user_usage AS (
-- The owner's workspaces used within the lookback window, split into
-- active and recently-deleted counts.
SELECT
w.template_id,
COUNT(*) FILTER (WHERE NOT w.deleted) AS active_count,
COUNT(*) FILTER (WHERE w.deleted) AS deleted_recent_count,
MAX(w.last_used_at) :: timestamptz AS last_used_at
FROM
workspaces w
WHERE
w.owner_id = $4
AND w.template_id = ANY($1 :: uuid[])
AND w.last_used_at > $5 :: timestamptz
AND CASE
WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000' :: uuid THEN
w.organization_id = $3
ELSE true
END
GROUP BY
w.template_id
)
SELECT
t.template_id :: uuid AS template_id,
COALESCE(u.active_count, 0) :: bigint AS active_count,
COALESCE(u.deleted_recent_count, 0) :: bigint AS deleted_recent_count,
u.last_used_at,
COALESCE(o.org_devs, 0) :: bigint AS org_devs
FROM
unnest($1 :: uuid[]) AS t(template_id)
LEFT JOIN user_usage u ON u.template_id = t.template_id
LEFT JOIN org_usage o ON o.template_id = t.template_id
`
type GetTemplateRankingSignalsByOwnerIDParams struct {
TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"`
PrebuildsUserID uuid.UUID `db:"prebuilds_user_id" json:"prebuilds_user_id"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
LookbackCutoff time.Time `db:"lookback_cutoff" json:"lookback_cutoff"`
}
type GetTemplateRankingSignalsByOwnerIDRow struct {
TemplateID uuid.UUID `db:"template_id" json:"template_id"`
ActiveCount int64 `db:"active_count" json:"active_count"`
DeletedRecentCount int64 `db:"deleted_recent_count" json:"deleted_recent_count"`
LastUsedAt sql.NullTime `db:"last_used_at" json:"last_used_at"`
OrgDevs int64 `db:"org_devs" json:"org_devs"`
}
// GetTemplateRankingSignalsByOwnerID returns raw template-ranking signals for
// one owner: in-window active and recently-deleted workspace counts, the last
// in-window usage, and distinct active developers per template. The affinity
// score is computed in Go (see listtemplates.go) so the ranking policy and
// its confidence thresholds live in one place.
func (q *sqlQuerier) GetTemplateRankingSignalsByOwnerID(ctx context.Context, arg GetTemplateRankingSignalsByOwnerIDParams) ([]GetTemplateRankingSignalsByOwnerIDRow, error) {
rows, err := q.db.QueryContext(ctx, getTemplateRankingSignalsByOwnerID,
pq.Array(arg.TemplateIDs),
arg.PrebuildsUserID,
arg.OrganizationID,
arg.OwnerID,
arg.LookbackCutoff,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetTemplateRankingSignalsByOwnerIDRow
for rows.Next() {
var i GetTemplateRankingSignalsByOwnerIDRow
if err := rows.Scan(
&i.TemplateID,
&i.ActiveCount,
&i.DeletedRecentCount,
&i.LastUsedAt,
&i.OrgDevs,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getWorkspaceACLByID = `-- name: GetWorkspaceACLByID :one
SELECT
group_acl as groups,
+59
View File
@@ -497,6 +497,65 @@ LEFT JOIN workspaces ON workspaces.template_id = templates.id AND workspaces.del
WHERE templates.id = ANY(@template_ids :: uuid[])
GROUP BY templates.id;
-- name: GetTemplateRankingSignalsByOwnerID :many
-- GetTemplateRankingSignalsByOwnerID returns raw template-ranking signals for
-- one owner: in-window active and recently-deleted workspace counts, the last
-- in-window usage, and distinct active developers per template. The affinity
-- score is computed in Go (see listtemplates.go) so the ranking policy and
-- its confidence thresholds live in one place.
WITH org_usage AS (
-- Distinct developers with a non-deleted workspace; the prebuilds system
-- user is excluded so unclaimed prebuilds do not inflate popularity.
SELECT
w.template_id,
COUNT(DISTINCT w.owner_id) AS org_devs
FROM
workspaces w
WHERE
w.template_id = ANY(@template_ids :: uuid[])
AND NOT w.deleted
AND w.owner_id != @prebuilds_user_id :: uuid
AND CASE
WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000' :: uuid THEN
w.organization_id = @organization_id
ELSE true
END
GROUP BY
w.template_id
),
user_usage AS (
-- The owner's workspaces used within the lookback window, split into
-- active and recently-deleted counts.
SELECT
w.template_id,
COUNT(*) FILTER (WHERE NOT w.deleted) AS active_count,
COUNT(*) FILTER (WHERE w.deleted) AS deleted_recent_count,
MAX(w.last_used_at) :: timestamptz AS last_used_at
FROM
workspaces w
WHERE
w.owner_id = @owner_id
AND w.template_id = ANY(@template_ids :: uuid[])
AND w.last_used_at > @lookback_cutoff :: timestamptz
AND CASE
WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000' :: uuid THEN
w.organization_id = @organization_id
ELSE true
END
GROUP BY
w.template_id
)
SELECT
t.template_id :: uuid AS template_id,
COALESCE(u.active_count, 0) :: bigint AS active_count,
COALESCE(u.deleted_recent_count, 0) :: bigint AS deleted_recent_count,
u.last_used_at,
COALESCE(o.org_devs, 0) :: bigint AS org_devs
FROM
unnest(@template_ids :: uuid[]) AS t(template_id)
LEFT JOIN user_usage u ON u.template_id = t.template_id
LEFT JOIN org_usage o ON o.template_id = t.template_id;
-- name: InsertWorkspace :one
INSERT INTO
workspaces (