diff --git a/cli/aibridged_internal_test.go b/cli/aibridged_internal_test.go index 20d0164502..dee00f841f 100644 --- a/cli/aibridged_internal_test.go +++ b/cli/aibridged_internal_test.go @@ -160,7 +160,7 @@ func TestBuildProviders(t *testing.T) { t.Run("LegacyBedrockWithoutAnthropicKey", func(t *testing.T) { t.Parallel() // Bedrock credentials alone should be enough to create an - // Anthropic provider — no CODER_AIBRIDGE_ANTHROPIC_KEY needed. + // Anthropic provider. No CODER_AIBRIDGE_ANTHROPIC_KEY needed. cfg := codersdk.AIBridgeConfig{} cfg.LegacyBedrock.Region = serpent.String("us-west-2") cfg.LegacyBedrock.AccessKey = serpent.String("AKID") diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 47f38d4d0a..854f25bdb8 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -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 diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 9565455ffb..477c32a6c7 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -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() diff --git a/coderd/database/dbauthz/setup_test.go b/coderd/database/dbauthz/setup_test.go index bab2cac91c..be99ee7eec 100644 --- a/coderd/database/dbauthz/setup_test.go +++ b/coderd/database/dbauthz/setup_test.go @@ -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{} diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index ee9e80d781..e6561186b0 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -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) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 7c7c3aec7e..4898f560bd 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -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() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 4645255530..4a45417246 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index cc5301a857..cea0c61e0c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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, diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index c42caef876..c9ed2ed446 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -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 ( diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index f3d0c870ef..5ad789e52e 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -10844,7 +10844,7 @@ func TestChatSystemPrompt(t *testing.T) { const workspaceAwareness = `No workspace is attached to this chat yet. Do not create or start a workspace by default. Many requests can be completed using the conversation, provider tools such as web_search when available, or configured external MCP tools. Workspace tools such as execute, read_file, write_file, and edit_files require an attached workspace. Only call create_workspace or start_workspace when the user explicitly asks for a workspace-backed task, or when the task cannot be completed without inspecting, editing, or running files in a workspace. -If a workspace is needed, use list_templates and read_template as needed before create_workspace.` +If a workspace is needed, use list_templates before create_workspace and follow its next_step. Call read_template only when you need template parameter or preset details.` updateChatSystemPrompt := func(t *testing.T, ctx context.Context, req codersdk.UpdateChatSystemPromptRequest) { t.Helper() diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 09ad56ca66..d766db4504 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -4587,7 +4587,15 @@ func TestWorkspaceDormant(t *testing.T) { require.NoError(t, err) // Should be able to stop a workspace while it is dormant. - coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) + testutil.Eventually(ctx, t, func(context.Context) bool { + return auditor.Contains(t, database.AuditLog{ + ResourceID: workspace.LatestBuild.ID, + ResourceType: database.ResourceTypeWorkspaceBuild, + Action: database.AuditActionStop, + StatusCode: http.StatusOK, + }) + }, testutil.IntervalFast) // Reset the auditor auditor.ResetLogs() diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 0473f554f1..0e18f600b6 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4199,6 +4199,8 @@ func (p *Server) appendRootChatTools( tools = append(tools, chattool.ListTemplates(p.db, opts.chat.OrganizationID, chattool.ListTemplatesOptions{ OwnerID: opts.chat.OwnerID, + Logger: p.logger, + Clock: p.clock, AllowedTemplateIDs: p.chatTemplateAllowlist, }), chattool.ReadTemplate(p.db, opts.chat.OrganizationID, chattool.ReadTemplateOptions{ diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 771a252369..03ff68b34a 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -11053,6 +11053,171 @@ func TestChatTemplateAllowlistEnforcement(t *testing.T) { "create_workspace for blocked template should be rejected") } +func TestChatAsksUserWhenListTemplatesRequiresSelection(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + + var tplCode, tplDocker database.Template + var callCount atomic.Int32 + var sawSelectionRule atomic.Bool + var sawSelectionRequiredResult atomic.Bool + + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + + switch callCount.Add(1) { + case 1: + promptAndTools := string(req.RawBody) + for _, message := range req.Messages { + promptAndTools += "\n" + message.Content + } + if strings.Contains(promptAndTools, "follow its next_step") { + sawSelectionRule.Store(true) + } + return chattest.OpenAIStreamingResponse( + chattest.OpenAIToolCallChunk("list_templates", `{}`), + ) + case 2: + if listTemplatesResultRequiresUserSelection(req.Messages) { + sawSelectionRequiredResult.Store(true) + return chattest.OpenAIStreamingResponse( + chattest.OpenAITextChunks( + "I found two templates, typescript-alpha and Docker Containers. Which template should I use?", + )..., + ) + } + + return chattest.OpenAIStreamingResponse( + chattest.OpenAIToolCallChunk("create_workspace", + fmt.Sprintf(`{"template_id":%q}`, tplCode.ID.String())), + ) + default: + return chattest.OpenAIStreamingResponse( + chattest.OpenAITextChunks("Done.")..., + ) + } + }) + + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + tplCode = dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "code-2", + DisplayName: "typescript-alpha", + Description: "this is a long description", + }) + tplDocker = dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "docker", + DisplayName: "Docker Containers", + Description: "Provision Docker containers as Coder workspaces", + }) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.CreateWorkspace = func( + context.Context, + uuid.UUID, + codersdk.CreateWorkspaceRequest, + ) (codersdk.Workspace, error) { + t.Error("create_workspace should not be called when list_templates requires user selection") + return codersdk.Workspace{}, xerrors.New("unexpected create_workspace call") + } + }) + + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + APIKeyID: testAPIKeyID(t, db, user.ID), + Title: "ask-template-selection-test", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("Create a workspace."), + }, + }) + require.NoError(t, err) + + var chatResult database.Chat + require.Eventually(t, func() bool { + got, getErr := db.GetChatByID(ctx, chat.ID) + if getErr != nil { + return false + } + chatResult = got + return got.Status == database.ChatStatusWaiting || got.Status == database.ChatStatusError + }, testutil.WaitLong, testutil.IntervalFast) + + if chatResult.Status == database.ChatStatusError { + require.FailNowf(t, "chat run failed", "last_error=%q", chatLastErrorMessage(chatResult.LastError)) + } + + require.True(t, sawSelectionRule.Load(), "model request should include the next_step selection rule") + require.True(t, sawSelectionRequiredResult.Load(), "model should receive a list_templates result requiring user selection") + + messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ + ChatID: chat.ID, + AfterID: 0, + }) + require.NoError(t, err) + + var listTemplatesResult map[string]any + var assistantText string + var sawCreateWorkspaceResult bool + for _, message := range messages { + parts, parseErr := chatprompt.ParseContent(message) + require.NoError(t, parseErr) + for _, part := range parts { + switch { + case part.Type == codersdk.ChatMessagePartTypeToolResult && part.ToolName == "list_templates": + require.NoError(t, json.Unmarshal(part.Result, &listTemplatesResult)) + case part.Type == codersdk.ChatMessagePartTypeToolResult && part.ToolName == "create_workspace": + sawCreateWorkspaceResult = true + case message.Role == database.ChatMessageRoleAssistant && part.Type == codersdk.ChatMessagePartTypeText: + assistantText += part.Text + } + } + } + + require.NotNil(t, listTemplatesResult, "expected list_templates tool result") + require.Equal(t, chattool.NextStepAskUser, listTemplatesResult["next_step"]) + require.NotContains(t, listTemplatesResult, "recommended_template_id") + require.Contains(t, listTemplatesResult["templates"], any(map[string]any{ + "id": tplCode.ID.String(), + "name": "code-2", + "display_name": "typescript-alpha", + "description": "this is a long description", + })) + require.Contains(t, listTemplatesResult["templates"], any(map[string]any{ + "id": tplDocker.ID.String(), + "name": "docker", + "display_name": "Docker Containers", + "description": "Provision Docker containers as Coder workspaces", + })) + require.False(t, sawCreateWorkspaceResult, "agent should ask instead of calling create_workspace") + require.Contains(t, assistantText, "Which template should I use?") +} + +func listTemplatesResultRequiresUserSelection(messages []chattest.OpenAIMessage) bool { + for _, message := range messages { + if message.Role != "tool" || !json.Valid([]byte(message.Content)) { + continue + } + + var result map[string]any + if err := json.Unmarshal([]byte(message.Content), &result); err != nil { + continue + } + if result["next_step"] == chattool.NextStepAskUser { + return true + } + } + return false +} + // TestCreateChatImmediatelyProcessesNewChat verifies that CreateChat // starts processing a new chat without waiting for the acquire ticker // to fire. The ticker interval is set to an hour so it never fires diff --git a/coderd/x/chatd/chattool/createworkspace.go b/coderd/x/chatd/chattool/createworkspace.go index 168eacb186..a20db20ac5 100644 --- a/coderd/x/chatd/chattool/createworkspace.go +++ b/coderd/x/chatd/chattool/createworkspace.go @@ -77,8 +77,8 @@ type CreateWorkspaceOptions struct { type createWorkspaceArgs struct { TemplateID string `json:"template_id" description:"The UUIDv4 of the template to create the workspace from. Obtain this from list_templates."` Name string `json:"name,omitempty" description:"The name of the workspace to create. If not provided, a random name will be generated."` - Parameters map[string]string `json:"parameters,omitempty" description:"Key-value pairs of template parameters to use when creating the workspace. Obtain available parameters from read_template."` - PresetID string `json:"preset_id,omitempty" description:"The UUIDv4 of a template version preset to use. Obtain available presets from read_template. When provided, the preset's parameters are applied automatically and the workspace may claim a prebuilt instance for faster startup."` + Parameters map[string]string `json:"parameters,omitempty" description:"Key-value pairs of template parameters to use when creating the workspace. Obtain available parameters from read_template when needed."` + PresetID string `json:"preset_id,omitempty" description:"The UUIDv4 of a template version preset to use. Obtain available presets from read_template when needed. When provided, the preset's parameters are applied automatically and the workspace may claim a prebuilt instance for faster startup."` } // CreateWorkspace returns a tool that creates a new workspace from a @@ -95,15 +95,12 @@ func CreateWorkspace(db database.Store, organizationID, chatID uuid.UUID, option "or when the user explicitly asks for one. Do not use this as a "+ "default first step for requests answerable from conversation "+ "context, provider tools, or external MCP tools. Requires a "+ - "template_id (from list_templates). Optionally provide "+ - "a name and parameter values (from read_template). "+ - "If no name is given, one will be generated. "+ - "Provide a preset_id (from read_template) to apply "+ - "preset parameters and potentially claim a prebuilt "+ - "workspace for faster startup. "+ - "This tool is idempotent. If the chat already has a "+ - "workspace that is building or running, the existing "+ - "workspace is returned.", + "template_id from list_templates; follow its "+NextStepField+" "+ + "before calling. Optionally provide a name (one is generated if "+ + "omitted), parameter values, and a preset_id from read_template "+ + "to apply preset parameters and potentially claim a prebuilt "+ + "workspace for faster startup. Idempotent: if the chat already "+ + "has a workspace building or running, it is returned.", func(ctx context.Context, args createWorkspaceArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { if options.CreateFn == nil { return fantasy.NewTextErrorResponse("workspace creator is not configured"), nil diff --git a/coderd/x/chatd/chattool/listtemplates.go b/coderd/x/chatd/chattool/listtemplates.go index 3c6d31c1b0..09828b0b6b 100644 --- a/coderd/x/chatd/chattool/listtemplates.go +++ b/coderd/x/chatd/chattool/listtemplates.go @@ -5,49 +5,127 @@ import ( "context" "database/sql" "maps" + "math" "slices" "strings" + "time" "charm.land/fantasy" "github.com/google/uuid" "golang.org/x/xerrors" + "cdr.dev/slog/v3" "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" + "github.com/coder/quartz" ) -const listTemplatesPageSize = 10 +const ( + listTemplatesPageSize = 10 -// ListTemplatesOptions configures the list_templates tool. + // Minimum active developers before organization popularity alone is a + // confident recommendation. + listTemplatesMinActiveDevelopersForRecommendation = 2 + + // Affinity ("frecency") parameters: recency-decayed personal usage plus + // log-scaled organization popularity. The score is computed in Go so the + // ranking policy and its confidence thresholds live in one place. + listTemplatesLookbackDays = 60 + listTemplatesHalfLife = 14 * 24 * time.Hour + listTemplatesPersonalWeight = 10.0 + listTemplatesOrgWeight = 1.0 + listTemplatesDeletedWeight = 0.5 +) + +var ( + // Confidence floor: organization popularity alone is confident at the + // active-developer minimum. + minConfidentAffinityScore = listTemplatesOrgWeight * math.Log1p(listTemplatesMinActiveDevelopersForRecommendation) + + // Required rank-1 lead over rank 2, derived so "2 developers versus 1" + // recommends while "16 versus 15" does not. + minConfidentGap = listTemplatesOrgWeight * (math.Log1p(listTemplatesMinActiveDevelopersForRecommendation) - math.Log1p(listTemplatesMinActiveDevelopersForRecommendation-1)) +) + +// affinityScoreEpsilon absorbs float rounding at threshold boundaries. +const affinityScoreEpsilon = 1e-9 + +func affinityScoreAtLeast(score, threshold float64) bool { + return score >= threshold-affinityScoreEpsilon +} + +// NextStepField is the list_templates result field carrying the instruction +// the model should follow next. Tool descriptions and prompts reference it +// by name. +const NextStepField = "next_step" + +// Next-step instructions returned with every list_templates result. +const ( + NextStepUseRecommended = "Use recommended_template_id with create_workspace. Call read_template first only if you need parameter or preset details." + NextStepAskUser = "Do not call create_workspace yet. Ask the user to choose a template, unless they already named one." + NextStepNoMatches = "No templates matched the query. Retry without a query or ask the user." + NextStepNoTemplates = "No templates are available to this chat. Inform the user." +) + +const ( + queryScoreExactName = 4 + queryScoreNamePrefix = 3 + queryScoreNameContains = 2 + queryScoreDescriptionMatch = 1 +) + +// ListTemplatesOptions configures the list_templates tool. OwnerID is +// required; Clock defaults to a real clock when nil. AllowedTemplateIDs +// optionally restricts which templates can be returned. type ListTemplatesOptions struct { OwnerID uuid.UUID + Logger slog.Logger + Clock quartz.Clock AllowedTemplateIDs func() map[uuid.UUID]bool } type listTemplatesArgs struct { - Query string `json:"query,omitempty" description:"Optional text to filter templates by name or description."` - Page int `json:"page,omitempty" description:"Page number for pagination (starts at 1). Each page returns up to 10 templates."` + Query string `json:"query,omitempty" description:"Optional text to filter templates by name, display name, or description."` + Page int `json:"page,omitempty" description:"Page number (starts at 1)."` } -// 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. -// db must not be nil. +type rankedTemplate struct { + Template database.Template + QueryScore int + Signals templateRankingSignals + AffinityScore float64 +} + +// templateRankingSignals holds the per-template ranking inputs returned by +// GetTemplateRankingSignalsByOwnerID. +type templateRankingSignals struct { + ActiveCount int64 + DeletedRecentCount int64 + LastUsedAt time.Time + OrgDevs int64 +} + +// ListTemplates returns a tool that lists workspace templates as a ranked +// shortlist, ordered by query relevance, the user's recent usage, and +// organization popularity. db must not be nil. func ListTemplates(db database.Store, organizationID uuid.UUID, options ListTemplatesOptions) fantasy.AgentTool { + clock := options.Clock + if clock == nil { + clock = quartz.NewReal() + } + 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.", + "List workspace templates as a ranked shortlist, optionally filtered "+ + "by a query matching template name, display name, or description. "+ + "Follow the "+NextStepField+" field in the result. Returns 10 per "+ + "page; fetch next_page only when no listed template fits the request.", func(ctx context.Context, args listTemplatesArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { ctx, err := asOwner(ctx, db, options.OwnerID) if err != nil { - return fantasy.NewTextErrorResponse(err.Error()), nil + return fantasy.NewTextErrorResponse(xerrors.Errorf("authorize list_templates owner: %w", err).Error()), nil } filterParams := database.GetTemplatesWithFilterParams{ @@ -58,10 +136,6 @@ func ListTemplates(db database.Store, organizationID uuid.UUID, options ListTemp Valid: true, }, } - query := strings.TrimSpace(args.Query) - if query != "" { - filterParams.FuzzyName = query - } var allowlist map[uuid.UUID]bool if options.AllowedTemplateIDs != nil { @@ -75,78 +149,284 @@ func ListTemplates(db database.Store, organizationID uuid.UUID, options ListTemp 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 := db.GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx, templateIDs) + query := strings.TrimSpace(args.Query) + visibleTemplateCount := len(templates) + ranked := scoreTemplateCandidates(templates, query) - if countErr == nil { - for _, row := range rows { - ownerCounts[row.TemplateID] = row.UniqueOwnersSum - } - } + templateIDs := make([]uuid.UUID, len(ranked)) + for i, t := range ranked { + templateIDs[i] = t.Template.ID + } + now := clock.Now() + signalsByTemplate, signalsErr := loadTemplateRankingSignals( + ctx, db, options.OwnerID, organizationID, templateIDs, now, + ) + if signalsErr != nil { + options.Logger.Warn(ctx, "failed to load template ranking signals", + slog.F("owner_id", options.OwnerID), + slog.F("organization_id", organizationID), + slog.F("template_count", len(templateIDs)), + slog.Error(signalsErr), + ) } - // Sort by active developer count descending. - slices.SortStableFunc(templates, func(a, b database.Template) int { - return cmp.Compare(ownerCounts[b.ID], ownerCounts[a.ID]) - }) - // Paginate. + for i := range ranked { + ranked[i].Signals = signalsByTemplate[ranked[i].Template.ID] + ranked[i].AffinityScore = computeAffinityScore(ranked[i].Signals, now) + } + + rankTemplates(ranked, query) + recommendedID, nextStep := selectTemplateRecommendation( + ranked, + visibleTemplateCount, + signalsErr, + ) + 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] + totalCount := len(ranked) + start := min((page-1)*listTemplatesPageSize, totalCount) + end := min(start+listTemplatesPageSize, totalCount) - items := make([]map[string]any, 0, len(pageTemplates)) - for _, t := range pageTemplates { - item := map[string]any{ - "id": t.ID.String(), - "name": t.Name, - "organization_id": t.OrganizationID.String(), - } - 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) + items := make([]map[string]any, 0, end-start) + for _, t := range ranked[start:end] { + items = append(items, templateItem(t)) } - return toolResponse(map[string]any{ + result := map[string]any{ "templates": items, - "count": len(items), "page": page, - "total_pages": totalPages, - "total_count": totalCount, - }), nil + NextStepField: nextStep, + } + if end < totalCount { + result["next_page"] = page + 1 + } + if recommendedID != uuid.Nil { + result["recommended_template_id"] = recommendedID.String() + } + return toolResponse(result), 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 scoreTemplateCandidates(templates []database.Template, query string) []rankedTemplate { + candidates := make([]rankedTemplate, 0, len(templates)) + for _, t := range templates { + queryScore := templateQueryScore(t, query) + if query != "" && queryScore == 0 { + continue + } + candidates = append(candidates, rankedTemplate{ + Template: t, + QueryScore: queryScore, + }) + } + return candidates +} + +func loadTemplateRankingSignals( + ctx context.Context, + db database.Store, + ownerID uuid.UUID, + organizationID uuid.UUID, + templateIDs []uuid.UUID, + now time.Time, +) (map[uuid.UUID]templateRankingSignals, error) { + signals := make(map[uuid.UUID]templateRankingSignals) + if len(templateIDs) == 0 { + return signals, nil + } + + // Runs with the owner's permissions; no system escalation. See the + // dbauthz GetTemplateRankingSignalsByOwnerID authorization notes. + rows, err := db.GetTemplateRankingSignalsByOwnerID(ctx, database.GetTemplateRankingSignalsByOwnerIDParams{ + TemplateIDs: templateIDs, + OwnerID: ownerID, + OrganizationID: organizationID, + PrebuildsUserID: database.PrebuildsSystemUserID, + LookbackCutoff: now.Add(-listTemplatesLookbackDays * 24 * time.Hour), + }) + if err != nil { + return signals, err + } + for _, row := range rows { + s := templateRankingSignals{ + ActiveCount: row.ActiveCount, + DeletedRecentCount: row.DeletedRecentCount, + OrgDevs: row.OrgDevs, + } + if row.LastUsedAt.Valid { + s.LastUsedAt = row.LastUsedAt.Time + } + signals[row.TemplateID] = s + } + return signals, nil +} + +// computeAffinityScore folds the raw signals into a single "frecency" score: +// recency-decayed personal usage plus log-scaled organization popularity. +func computeAffinityScore(s templateRankingSignals, now time.Time) float64 { + personal := 0.0 + if !s.LastUsedAt.IsZero() { + count := float64(s.ActiveCount) + listTemplatesDeletedWeight*float64(s.DeletedRecentCount) + age := now.Sub(s.LastUsedAt) + if age < 0 { + age = 0 + } + decay := math.Pow(0.5, float64(age)/float64(listTemplatesHalfLife)) + personal = listTemplatesPersonalWeight * count * decay + } + org := listTemplatesOrgWeight * math.Log1p(float64(s.OrgDevs)) + return personal + org +} + +// rankTemplates orders by query relevance (when a query is present), then +// affinity score, then name and ID for determinism. +func rankTemplates(ranked []rankedTemplate, query string) { + slices.SortStableFunc(ranked, func(a, b rankedTemplate) int { + if query != "" { + if c := cmp.Compare(b.QueryScore, a.QueryScore); c != 0 { + return c + } + } + if c := cmp.Compare(b.AffinityScore, a.AffinityScore); c != 0 { + return c + } + if c := cmp.Compare(a.Template.Name, b.Template.Name); c != 0 { + return c + } + return cmp.Compare(a.Template.ID.String(), b.Template.ID.String()) + }) +} + +// selectTemplateRecommendation returns the recommended template (uuid.Nil for +// none) and the next-step instruction. A decisive query match recommends on +// its own; otherwise the affinity score must clear a floor and lead the +// runner-up by a margin. +func selectTemplateRecommendation( + ranked []rankedTemplate, + visibleTemplateCount int, + rankingSignalsErr error, +) (uuid.UUID, string) { + if len(ranked) == 0 { + if visibleTemplateCount == 0 { + return uuid.Nil, NextStepNoTemplates + } + return uuid.Nil, NextStepNoMatches + } + + top := ranked[0] + if visibleTemplateCount == 1 && len(ranked) == 1 { + return top.Template.ID, NextStepUseRecommended + } + + // A decisive query match recommends even when signals failed to load. + if top.QueryScore > 0 && (len(ranked) == 1 || top.QueryScore > ranked[1].QueryScore) { + return top.Template.ID, NextStepUseRecommended + } + + // Beyond a decisive query match, confidence comes from the affinity + // score, so a failed signal load means asking the user. + if rankingSignalsErr != nil { + return uuid.Nil, NextStepAskUser + } + + // Query tie: break it with a clear affinity gap. + if top.QueryScore > 0 { + if len(ranked) > 1 && affinityScoreAtLeast(top.AffinityScore-ranked[1].AffinityScore, minConfidentGap) { + return top.Template.ID, NextStepUseRecommended + } + return uuid.Nil, NextStepAskUser + } + + // No query: the affinity score alone decides. + if !affinityScoreAtLeast(top.AffinityScore, minConfidentAffinityScore) { + return uuid.Nil, NextStepAskUser + } + if len(ranked) > 1 && + affinityScoreAtLeast(ranked[1].AffinityScore, minConfidentAffinityScore) && + !affinityScoreAtLeast(top.AffinityScore-ranked[1].AffinityScore, minConfidentGap) { + return uuid.Nil, NextStepAskUser + } + return top.Template.ID, NextStepUseRecommended +} + +func templateItem(t rankedTemplate) map[string]any { + item := map[string]any{ + "id": t.Template.ID.String(), + "name": t.Template.Name, + } + if display := strings.TrimSpace(t.Template.DisplayName); display != "" { + item["display_name"] = display + } + if desc := strings.TrimSpace(t.Template.Description); desc != "" { + item["description"] = truncateRunes(desc, 200) + } + if t.Signals.OrgDevs > 0 { + item["active_developers"] = t.Signals.OrgDevs + } + if t.Signals.ActiveCount > 0 { + item["your_workspace_count"] = t.Signals.ActiveCount + } + if !t.Signals.LastUsedAt.IsZero() { + item["last_used_by_you"] = t.Signals.LastUsedAt.Format(time.RFC3339Nano) + } + return item +} + +func templateQueryScore(t database.Template, query string) int { + query = normalizeTemplateSearch(query) + queryCompact := compactTemplateSearch(query) + if query == "" || queryCompact == "" { + return 0 + } + + best := 0 + for _, field := range []string{t.Name, t.DisplayName} { + best = max(best, nameQueryScore(field, query, queryCompact)) + } + if best > 0 { + return best + } + desc := normalizeTemplateSearch(t.Description) + if strings.Contains(desc, query) || strings.Contains(compactTemplateSearch(desc), queryCompact) { + return queryScoreDescriptionMatch + } + return 0 +} + +// nameQueryScore returns the relevance tier of a single name-like field: +// exact match outranks prefix match, which outranks substring match. +func nameQueryScore(field, query, queryCompact string) int { + field = normalizeTemplateSearch(field) + if field == "" { + return 0 + } + fieldCompact := compactTemplateSearch(field) + switch { + case field == query || fieldCompact == queryCompact: + return queryScoreExactName + case strings.HasPrefix(field, query) || strings.HasPrefix(fieldCompact, queryCompact): + return queryScoreNamePrefix + case strings.Contains(field, query) || strings.Contains(fieldCompact, queryCompact): + return queryScoreNameContains + } + return 0 +} + +func normalizeTemplateSearch(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +var templateSearchCompactReplacer = strings.NewReplacer(" ", "", "-", "", "_", "") + +func compactTemplateSearch(value string) string { + return templateSearchCompactReplacer.Replace(value) +} + +// asOwner sets up a dbauthz context scoped to what the owner 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 { diff --git a/coderd/x/chatd/chattool/listtemplates_internal_test.go b/coderd/x/chatd/chattool/listtemplates_internal_test.go new file mode 100644 index 0000000000..af7674e1f9 --- /dev/null +++ b/coderd/x/chatd/chattool/listtemplates_internal_test.go @@ -0,0 +1,207 @@ +package chattool + +import ( + "math" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" +) + +func TestComputeAffinityScore(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) + + // No signals at all scores zero. + require.Zero(t, computeAffinityScore(templateRankingSignals{}, now)) + + // With no personal usage the score collapses to the log-scaled org term. + orgOnly := computeAffinityScore(templateRankingSignals{OrgDevs: 3}, now) + require.InDelta(t, listTemplatesOrgWeight*math.Log1p(3), orgOnly, 1e-9) + + // Org popularity is monotonic in the developer count. + require.Greater(t, + computeAffinityScore(templateRankingSignals{OrgDevs: 3}, now), + computeAffinityScore(templateRankingSignals{OrgDevs: 1}, now), + ) + + // Recency decay: the same usage counts more when it is more recent. + recent := computeAffinityScore(templateRankingSignals{ActiveCount: 2, LastUsedAt: now.Add(-1 * 24 * time.Hour)}, now) + stale := computeAffinityScore(templateRankingSignals{ActiveCount: 2, LastUsedAt: now.Add(-30 * 24 * time.Hour)}, now) + require.Greater(t, recent, stale) + + // Deleted workspaces contribute at reduced weight, so the same number of + // active workspaces outscores deleted ones. + last := now.Add(-1 * time.Hour) + activeOnly := computeAffinityScore(templateRankingSignals{ActiveCount: 2, LastUsedAt: last}, now) + deletedOnly := computeAffinityScore(templateRankingSignals{DeletedRecentCount: 2, LastUsedAt: last}, now) + require.Greater(t, activeOnly, deletedOnly) + require.Greater(t, deletedOnly, 0.0) + + // A future last_used_at clamps the age to zero rather than amplifying. + future := computeAffinityScore(templateRankingSignals{ActiveCount: 1, LastUsedAt: now.Add(time.Hour)}, now) + atNow := computeAffinityScore(templateRankingSignals{ActiveCount: 1, LastUsedAt: now}, now) + require.InDelta(t, atNow, future, 1e-9) +} + +func TestSelectTemplateRecommendation(t *testing.T) { + t.Parallel() + + loadErr := xerrors.New("signals failed to load") + + t.Run("NoTemplatesAvailable", func(t *testing.T) { + t.Parallel() + id, next := selectTemplateRecommendation(nil, 0, nil) + require.Equal(t, uuid.Nil, id) + require.Equal(t, NextStepNoTemplates, next) + }) + + t.Run("QueryFiltersEverything", func(t *testing.T) { + t.Parallel() + id, next := selectTemplateRecommendation(nil, 2, nil) + require.Equal(t, uuid.Nil, id) + require.Equal(t, NextStepNoMatches, next) + }) + + t.Run("OnlyAvailable", func(t *testing.T) { + t.Parallel() + only := uuid.New() + id, next := selectTemplateRecommendation( + []rankedTemplate{{Template: database.Template{ID: only}}}, 1, loadErr, + ) + require.Equal(t, only, id) + require.Equal(t, NextStepUseRecommended, next) + }) + + t.Run("DecisiveQueryRecommendsEvenWithLoadError", func(t *testing.T) { + t.Parallel() + top := uuid.New() + for _, err := range []error{nil, loadErr} { + id, next := selectTemplateRecommendation( + []rankedTemplate{ + {Template: database.Template{ID: top}, QueryScore: queryScoreExactName}, + {Template: database.Template{ID: uuid.New()}, QueryScore: queryScoreDescriptionMatch}, + }, 2, err, + ) + require.Equal(t, top, id) + require.Equal(t, NextStepUseRecommended, next) + } + }) + + t.Run("QueryTieBrokenByAffinityGap", func(t *testing.T) { + t.Parallel() + top := uuid.New() + id, next := selectTemplateRecommendation( + []rankedTemplate{ + {Template: database.Template{ID: top}, QueryScore: queryScoreNamePrefix, AffinityScore: 10, Signals: templateRankingSignals{ActiveCount: 1}}, + {Template: database.Template{ID: uuid.New()}, QueryScore: queryScoreNamePrefix, AffinityScore: 0}, + }, 2, nil, + ) + require.Equal(t, top, id) + require.Equal(t, NextStepUseRecommended, next) + }) + + t.Run("QueryTieWithSmallGapIsAmbiguous", func(t *testing.T) { + t.Parallel() + id, next := selectTemplateRecommendation( + []rankedTemplate{ + {Template: database.Template{ID: uuid.New()}, QueryScore: queryScoreNamePrefix, AffinityScore: 0.1}, + {Template: database.Template{ID: uuid.New()}, QueryScore: queryScoreNamePrefix, AffinityScore: 0}, + }, 2, nil, + ) + require.Equal(t, uuid.Nil, id) + require.Equal(t, NextStepAskUser, next) + }) + + t.Run("QueryTieWithLoadErrorAsksUser", func(t *testing.T) { + t.Parallel() + id, next := selectTemplateRecommendation( + []rankedTemplate{ + {Template: database.Template{ID: uuid.New()}, QueryScore: queryScoreNamePrefix}, + {Template: database.Template{ID: uuid.New()}, QueryScore: queryScoreNamePrefix}, + }, 2, loadErr, + ) + require.Equal(t, uuid.Nil, id) + require.Equal(t, NextStepAskUser, next) + }) + + t.Run("NoQueryNoSignal", func(t *testing.T) { + t.Parallel() + id, next := selectTemplateRecommendation( + []rankedTemplate{ + {Template: database.Template{ID: uuid.New()}}, + {Template: database.Template{ID: uuid.New()}}, + }, 2, nil, + ) + require.Equal(t, uuid.Nil, id) + require.Equal(t, NextStepAskUser, next) + }) + + t.Run("NoQueryWeakSignalBelowFloor", func(t *testing.T) { + t.Parallel() + // One active developer scores ln(2), below the ln(3) floor. + id, next := selectTemplateRecommendation( + []rankedTemplate{ + {Template: database.Template{ID: uuid.New()}, AffinityScore: math.Log1p(1), Signals: templateRankingSignals{OrgDevs: 1}}, + {Template: database.Template{ID: uuid.New()}, AffinityScore: 0}, + }, 2, nil, + ) + require.Equal(t, uuid.Nil, id) + require.Equal(t, NextStepAskUser, next) + }) + + t.Run("NoQueryConfidentWhenLeadsRunnerUp", func(t *testing.T) { + t.Parallel() + top := uuid.New() + id, next := selectTemplateRecommendation( + []rankedTemplate{ + {Template: database.Template{ID: top}, AffinityScore: math.Log1p(3), Signals: templateRankingSignals{OrgDevs: 3}}, + {Template: database.Template{ID: uuid.New()}, AffinityScore: math.Log1p(1), Signals: templateRankingSignals{OrgDevs: 1}}, + }, 2, nil, + ) + require.Equal(t, top, id) + require.Equal(t, NextStepUseRecommended, next) + }) + + t.Run("NoQueryAmbiguousWhenBothClearFloorAndClose", func(t *testing.T) { + t.Parallel() + id, next := selectTemplateRecommendation( + []rankedTemplate{ + {Template: database.Template{ID: uuid.New()}, AffinityScore: 1.20, Signals: templateRankingSignals{OrgDevs: 2}}, + {Template: database.Template{ID: uuid.New()}, AffinityScore: 1.15, Signals: templateRankingSignals{OrgDevs: 2}}, + }, 2, nil, + ) + require.Equal(t, uuid.Nil, id) + require.Equal(t, NextStepAskUser, next) + }) + + t.Run("NoQueryConfidentWhenBothClearFloorWithLargeGap", func(t *testing.T) { + t.Parallel() + top := uuid.New() + id, next := selectTemplateRecommendation( + []rankedTemplate{ + {Template: database.Template{ID: top}, AffinityScore: 2.0, Signals: templateRankingSignals{OrgDevs: 6}}, + {Template: database.Template{ID: uuid.New()}, AffinityScore: 1.2, Signals: templateRankingSignals{OrgDevs: 2}}, + }, 2, nil, + ) + require.Equal(t, top, id) + require.Equal(t, NextStepUseRecommended, next) + }) + + t.Run("NoQueryLoadErrorAsksUser", func(t *testing.T) { + t.Parallel() + id, next := selectTemplateRecommendation( + []rankedTemplate{ + {Template: database.Template{ID: uuid.New()}, AffinityScore: math.Log1p(3), Signals: templateRankingSignals{OrgDevs: 3}}, + {Template: database.Template{ID: uuid.New()}}, + }, 2, loadErr, + ) + require.Equal(t, uuid.Nil, id) + require.Equal(t, NextStepAskUser, next) + }) +} diff --git a/coderd/x/chatd/chattool/listtemplates_test.go b/coderd/x/chatd/chattool/listtemplates_test.go index 0cf25d2c43..060589ba90 100644 --- a/coderd/x/chatd/chattool/listtemplates_test.go +++ b/coderd/x/chatd/chattool/listtemplates_test.go @@ -3,7 +3,9 @@ package chattool_test import ( "context" "encoding/json" + "fmt" "testing" + "time" "charm.land/fantasy" "github.com/google/uuid" @@ -15,6 +17,7 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chattool" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" + "github.com/coder/quartz" ) func TestListTemplates_OrganizationFilter(t *testing.T) { @@ -82,6 +85,9 @@ func TestListTemplates_OrganizationFilter(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) templates := result["templates"].([]any) require.Len(t, templates, 2) + require.Equal(t, chattool.NextStepAskUser, result["next_step"]) + _, ok := result["recommended_template_id"] + require.False(t, ok) }) t.Run("ReadTemplate_CrossOrgRejected", func(t *testing.T) { @@ -121,6 +127,586 @@ func TestListTemplates_OrganizationFilter(t *testing.T) { }) } +func TestListTemplates_QueryMatchesDisplayNameAndDescription(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + db, _ := dbtestutil.NewDB(t) + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + + displayTemplate := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "tpl-42", + DisplayName: "Data Science Lab", + }) + descriptionTemplate := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "node-general", + Description: "A JavaScript and TypeScript workspace.", + }) + _ = dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "unrelated", + Description: "A plain Linux workspace.", + }) + + tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{ + OwnerID: user.ID, + }) + + result := runListTemplates(ctx, t, tool, `{"query":"Data Science"}`) + templates := listTemplateItems(t, result) + require.Len(t, templates, 1) + require.Equal(t, displayTemplate.ID.String(), templates[0]["id"]) + require.Equal(t, chattool.NextStepUseRecommended, result["next_step"]) + require.Equal(t, displayTemplate.ID.String(), result["recommended_template_id"]) + + result = runListTemplates(ctx, t, tool, `{"query":"TypeScript"}`) + templates = listTemplateItems(t, result) + require.Len(t, templates, 1) + require.Equal(t, descriptionTemplate.ID.String(), templates[0]["id"]) + require.Equal(t, chattool.NextStepUseRecommended, result["next_step"]) + require.Equal(t, descriptionTemplate.ID.String(), result["recommended_template_id"]) + + result = runListTemplates(ctx, t, tool, `{"query":"-"}`) + templates = listTemplateItems(t, result) + require.Empty(t, templates) + require.Equal(t, chattool.NextStepNoMatches, result["next_step"]) + _, ok := result["recommended_template_id"] + require.False(t, ok) + + result = runListTemplates(ctx, t, tool, `{"query":"does-not-exist"}`) + templates = listTemplateItems(t, result) + require.Empty(t, templates) + require.Equal(t, chattool.NextStepNoMatches, result["next_step"]) + _, ok = result["recommended_template_id"] + require.False(t, ok) +} + +func TestListTemplates_QueryScoreTiers(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + db, _ := dbtestutil.NewDB(t) + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + + exact := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "python", + }) + prefix := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "python-alpha", + }) + contains := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "go-python", + }) + description := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "generic-dev", + Description: "Python-capable general environment.", + }) + + tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{ + OwnerID: user.ID, + }) + result := runListTemplates(ctx, t, tool, `{"query":"python"}`) + templates := listTemplateItems(t, result) + require.Len(t, templates, 4) + require.Equal(t, exact.ID.String(), templates[0]["id"]) + require.Equal(t, prefix.ID.String(), templates[1]["id"]) + require.Equal(t, contains.ID.String(), templates[2]["id"]) + require.Equal(t, description.ID.String(), templates[3]["id"]) + + hyphenated := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "python-gpu", + }) + result = runListTemplates(ctx, t, tool, `{"query":"python gpu"}`) + templates = listTemplateItems(t, result) + require.Len(t, templates, 1) + require.Equal(t, hyphenated.ID.String(), templates[0]["id"]) + + descriptionHyphenated := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "ml-tools", + Description: "Includes machine-learning libraries.", + }) + result = runListTemplates(ctx, t, tool, `{"query":"machine learning"}`) + templates = listTemplateItems(t, result) + require.Len(t, templates, 1) + require.Equal(t, descriptionHyphenated.ID.String(), templates[0]["id"]) +} + +func TestListTemplates_RanksAllCandidatesBeforePagination(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + db, _ := dbtestutil.NewDB(t) + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + + var target database.Template + for i := range 11 { + tpl := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: fmt.Sprintf("template-%02d", i), + }) + if i == 10 { + target = tpl + } + } + dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: org.ID, + TemplateID: target.ID, + LastUsedAt: time.Now().Add(-time.Hour), + }) + + tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{ + OwnerID: user.ID, + }) + result := runListTemplates(ctx, t, tool, `{}`) + templates := listTemplateItems(t, result) + require.Len(t, templates, 10) + require.Equal(t, float64(1), result["page"]) + require.Equal(t, float64(2), result["next_page"]) + require.Equal(t, target.ID.String(), templates[0]["id"]) + require.Equal(t, float64(1), templates[0]["your_workspace_count"]) + require.NotEmpty(t, templates[0]["last_used_by_you"]) + require.Equal(t, chattool.NextStepUseRecommended, result["next_step"]) + require.Equal(t, target.ID.String(), result["recommended_template_id"]) + + result = runListTemplates(ctx, t, tool, `{"page":2}`) + templates = listTemplateItems(t, result) + require.Len(t, templates, 1) + require.Equal(t, float64(2), result["page"]) + _, hasNextPage := result["next_page"] + require.False(t, hasNextPage) +} + +func TestListTemplates_QueryRelevanceOutranksPersonalUsage(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + db, _ := dbtestutil.NewDB(t) + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + + target := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "python-gpu", + Description: "GPU workspace.", + }) + used := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "generic-dev", + Description: "Python-capable general environment.", + }) + dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: org.ID, + TemplateID: used.ID, + LastUsedAt: time.Now().Add(-14 * 24 * time.Hour), + }) + + tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{ + OwnerID: user.ID, + }) + result := runListTemplates(ctx, t, tool, `{"query":"python"}`) + templates := listTemplateItems(t, result) + require.Len(t, templates, 2) + require.Equal(t, target.ID.String(), templates[0]["id"]) + require.Equal(t, used.ID.String(), templates[1]["id"]) + require.Equal(t, chattool.NextStepUseRecommended, result["next_step"]) + require.Equal(t, target.ID.String(), result["recommended_template_id"]) +} + +func TestListTemplates_PersonalUsageBreaksEqualQueryScoreTie(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + db, _ := dbtestutil.NewDB(t) + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + + unused := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "python-alpha", + }) + used := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "python-beta", + }) + dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: org.ID, + TemplateID: used.ID, + LastUsedAt: time.Now().Add(-time.Hour), + }) + + tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{ + OwnerID: user.ID, + }) + result := runListTemplates(ctx, t, tool, `{"query":"python"}`) + templates := listTemplateItems(t, result) + require.Len(t, templates, 2) + require.Equal(t, used.ID.String(), templates[0]["id"]) + require.Equal(t, unused.ID.String(), templates[1]["id"]) + require.Equal(t, chattool.NextStepUseRecommended, result["next_step"]) + require.Equal(t, used.ID.String(), result["recommended_template_id"]) +} + +func TestListTemplates_OrgPopularityFallback(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + db, _ := dbtestutil.NewDB(t) + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + + popular := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "popular-template", + }) + lessPopular := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "less-popular-template", + }) + for range 2 { + otherUser := dbgen.User(t, db, database.User{}) + dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: otherUser.ID, + OrganizationID: org.ID, + TemplateID: popular.ID, + }) + } + otherUser := dbgen.User(t, db, database.User{}) + dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: otherUser.ID, + OrganizationID: org.ID, + TemplateID: lessPopular.ID, + }) + + tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{ + OwnerID: user.ID, + }) + result := runListTemplates(ctx, t, tool, `{}`) + templates := listTemplateItems(t, result) + require.Len(t, templates, 2) + require.Equal(t, popular.ID.String(), templates[0]["id"]) + require.Equal(t, float64(2), templates[0]["active_developers"]) + require.Equal(t, chattool.NextStepUseRecommended, result["next_step"]) + require.Equal(t, popular.ID.String(), result["recommended_template_id"]) +} + +func TestListTemplates_WeakOrgPopularityDoesNotRecommend(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + db, _ := dbtestutil.NewDB(t) + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + + usedByOne := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "used-by-one", + }) + unused := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "unused", + }) + otherUser := dbgen.User(t, db, database.User{}) + dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: otherUser.ID, + OrganizationID: org.ID, + TemplateID: usedByOne.ID, + }) + + tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{ + OwnerID: user.ID, + }) + result := runListTemplates(ctx, t, tool, `{}`) + templates := listTemplateItems(t, result) + require.Len(t, templates, 2) + require.Equal(t, usedByOne.ID.String(), templates[0]["id"]) + require.Equal(t, unused.ID.String(), templates[1]["id"]) + require.Equal(t, float64(1), templates[0]["active_developers"]) + require.Equal(t, chattool.NextStepAskUser, result["next_step"]) + _, ok := result["recommended_template_id"] + require.False(t, ok) +} + +func TestListTemplates_StalePersonalUsageDoesNotRecommend(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + clock := quartz.NewMock(t) + now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) + clock.Set(now).MustWait(ctx) + db, _ := dbtestutil.NewDB(t) + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + + oldUsage := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "old-usage", + }) + unused := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "unused", + }) + dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: org.ID, + TemplateID: oldUsage.ID, + LastUsedAt: now.Add(-180 * 24 * time.Hour), + }) + + tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{ + OwnerID: user.ID, + Clock: clock, + }) + result := runListTemplates(ctx, t, tool, `{}`) + templates := listTemplateItems(t, result) + require.Len(t, templates, 2) + require.Equal(t, oldUsage.ID.String(), templates[0]["id"]) + require.Equal(t, unused.ID.String(), templates[1]["id"]) + // 180 days old is outside the 60-day lookback window. + _, hasCount := templates[0]["your_workspace_count"] + require.False(t, hasCount) + require.Equal(t, chattool.NextStepAskUser, result["next_step"]) + _, ok := result["recommended_template_id"] + require.False(t, ok) +} + +func TestListTemplates_StaleFrequentPersonalUsageDoesNotRecommend(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + clock := quartz.NewMock(t) + now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) + clock.Set(now).MustWait(ctx) + db, _ := dbtestutil.NewDB(t) + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + + staleUsage := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "stale-usage", + }) + unused := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "unused", + }) + // Stale usage decays out of the personal signal despite its frequency. + for range 2 { + dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: org.ID, + TemplateID: staleUsage.ID, + LastUsedAt: now.Add(-180 * 24 * time.Hour), + }) + } + + tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{ + OwnerID: user.ID, + Clock: clock, + }) + result := runListTemplates(ctx, t, tool, `{}`) + templates := listTemplateItems(t, result) + require.Len(t, templates, 2) + require.Equal(t, staleUsage.ID.String(), templates[0]["id"]) + require.Equal(t, unused.ID.String(), templates[1]["id"]) + require.Equal(t, chattool.NextStepAskUser, result["next_step"]) + _, ok := result["recommended_template_id"] + require.False(t, ok) + _, hasCount := templates[0]["your_workspace_count"] + require.False(t, hasCount) +} + +func TestListTemplates_RecentPersonalUsageRecommends(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + clock := quartz.NewMock(t) + now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) + clock.Set(now).MustWait(ctx) + db, _ := dbtestutil.NewDB(t) + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + + recentUsage := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "recent-usage", + }) + unused := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "unused", + }) + // Recent in-window usage is a confident signal. + for range 2 { + dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: org.ID, + TemplateID: recentUsage.ID, + LastUsedAt: now.Add(-2 * 24 * time.Hour), + }) + } + + tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{ + OwnerID: user.ID, + Clock: clock, + }) + result := runListTemplates(ctx, t, tool, `{}`) + templates := listTemplateItems(t, result) + require.Len(t, templates, 2) + require.Equal(t, recentUsage.ID.String(), templates[0]["id"]) + require.Equal(t, unused.ID.String(), templates[1]["id"]) + require.Equal(t, float64(2), templates[0]["your_workspace_count"]) + require.Equal(t, chattool.NextStepUseRecommended, result["next_step"]) + require.Equal(t, recentUsage.ID.String(), result["recommended_template_id"]) +} + +func TestListTemplates_DeletedRecentPersonalUsageShowsEvidence(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + clock := quartz.NewMock(t) + now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) + clock.Set(now).MustWait(ctx) + db, _ := dbtestutil.NewDB(t) + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + + deletedUsage := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "deleted-usage", + }) + unused := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "unused", + }) + dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: org.ID, + TemplateID: deletedUsage.ID, + LastUsedAt: now.Add(-2 * 24 * time.Hour), + Deleted: true, + }) + + tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{ + OwnerID: user.ID, + Clock: clock, + }) + result := runListTemplates(ctx, t, tool, `{}`) + templates := listTemplateItems(t, result) + require.Len(t, templates, 2) + require.Equal(t, deletedUsage.ID.String(), templates[0]["id"]) + require.Equal(t, unused.ID.String(), templates[1]["id"]) + require.NotEmpty(t, templates[0]["last_used_by_you"]) + _, hasActiveCount := templates[0]["your_workspace_count"] + require.False(t, hasActiveCount) + // Recent deleted usage alone clears the confidence floor. + require.Equal(t, chattool.NextStepUseRecommended, result["next_step"]) + require.Equal(t, deletedUsage.ID.String(), result["recommended_template_id"]) +} + +func TestListTemplates_AmbiguousTopMatches(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + db, _ := dbtestutil.NewDB(t) + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + + _ = dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "go-alpha", + }) + _ = dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + Name: "go-beta", + }) + + tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{ + OwnerID: user.ID, + }) + result := runListTemplates(ctx, t, tool, `{"query":"go"}`) + templates := listTemplateItems(t, result) + require.Len(t, templates, 2) + require.Equal(t, chattool.NextStepAskUser, result["next_step"]) + _, ok := result["recommended_template_id"] + require.False(t, ok) +} + //nolint:tparallel,paralleltest // Subtests share a single DB and run sequentially. func TestTemplateAllowlistEnforcement(t *testing.T) { t.Parallel() @@ -187,6 +773,8 @@ func TestTemplateAllowlistEnforcement(t *testing.T) { require.Len(t, templates, 1) m := templates[0].(map[string]any) require.Equal(t, t1.ID.String(), m["id"].(string)) + require.Equal(t, chattool.NextStepUseRecommended, result["next_step"]) + require.Equal(t, t1.ID.String(), result["recommended_template_id"]) }) t.Run("NoMatches", func(t *testing.T) { @@ -201,6 +789,9 @@ func TestTemplateAllowlistEnforcement(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) templates := result["templates"].([]any) require.Empty(t, templates) + require.Equal(t, chattool.NextStepNoTemplates, result["next_step"]) + _, ok := result["recommended_template_id"] + require.False(t, ok) }) }) @@ -301,3 +892,117 @@ func TestTemplateAllowlistEnforcement(t *testing.T) { }) }) } + +// TestGetTemplateRankingSignalsByOwnerID exercises the raw SQL signals query: +// the lookback window, the active/deleted split, and excluding the prebuilds +// system user from the organization developer count. +func TestGetTemplateRankingSignalsByOwnerID(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + db, _ := dbtestutil.NewDB(t) + + now := time.Now() + lookbackCutoff := now.Add(-60 * 24 * time.Hour) + + user := dbgen.User(t, db, database.User{}) + otherUser := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + for _, u := range []uuid.UUID{user.ID, otherUser.ID} { + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: u, OrganizationID: org.ID}) + } + + used := dbgen.Template(t, db, database.Template{OrganizationID: org.ID, CreatedBy: user.ID, Name: "used"}) + unused := dbgen.Template(t, db, database.Template{OrganizationID: org.ID, CreatedBy: user.ID, Name: "unused"}) + + activeLastUsedAt := now.Add(-2 * 24 * time.Hour) + deletedLastUsedAt := now.Add(-3 * 24 * time.Hour) + // Active, in-window workspace for the requesting user. + dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, OrganizationID: org.ID, TemplateID: used.ID, + LastUsedAt: activeLastUsedAt, + }) + // Recently-deleted, in-window workspace for the requesting user. + dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, OrganizationID: org.ID, TemplateID: used.ID, + LastUsedAt: deletedLastUsedAt, Deleted: true, + }) + // Outside the lookback window: excluded from in-window counts, still an org dev. + dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, OrganizationID: org.ID, TemplateID: used.ID, + LastUsedAt: now.Add(-90 * 24 * time.Hour), + }) + // Another developer's active workspace contributes to org popularity. + dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: otherUser.ID, OrganizationID: org.ID, TemplateID: used.ID, + LastUsedAt: now.Add(-1 * 24 * time.Hour), + }) + // The prebuilds system user must be excluded from the org developer count. + dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: database.PrebuildsSystemUserID, OrganizationID: org.ID, TemplateID: used.ID, + LastUsedAt: now.Add(-1 * 24 * time.Hour), + }) + + rows, err := db.GetTemplateRankingSignalsByOwnerID(ctx, database.GetTemplateRankingSignalsByOwnerIDParams{ + TemplateIDs: []uuid.UUID{used.ID, unused.ID}, + OwnerID: user.ID, + OrganizationID: org.ID, + PrebuildsUserID: database.PrebuildsSystemUserID, + LookbackCutoff: lookbackCutoff, + }) + require.NoError(t, err) + + byTemplate := make(map[uuid.UUID]database.GetTemplateRankingSignalsByOwnerIDRow, len(rows)) + for _, row := range rows { + byTemplate[row.TemplateID] = row + } + // The unnest LEFT JOIN returns a row for every requested template. + require.Len(t, byTemplate, 2) + + usedRow := byTemplate[used.ID] + require.Equal(t, int64(1), usedRow.ActiveCount, "only the in-window active workspace counts") + require.Equal(t, int64(1), usedRow.DeletedRecentCount, "the in-window deleted workspace counts") + require.Equal(t, int64(2), usedRow.OrgDevs, "user and otherUser count; prebuilds user is excluded") + require.True(t, usedRow.LastUsedAt.Valid) + require.WithinDuration(t, activeLastUsedAt, usedRow.LastUsedAt.Time, time.Microsecond) + + unusedRow := byTemplate[unused.ID] + require.Equal(t, int64(0), unusedRow.ActiveCount) + require.Equal(t, int64(0), unusedRow.DeletedRecentCount) + require.Equal(t, int64(0), unusedRow.OrgDevs) + require.False(t, unusedRow.LastUsedAt.Valid) +} + +func runListTemplates( + ctx context.Context, + t *testing.T, + tool fantasy.AgentTool, + input string, +) map[string]any { + t.Helper() + + resp, err := tool.Run(ctx, fantasy.ToolCall{ + ID: uuid.NewString(), + Name: "list_templates", + Input: input, + }) + require.NoError(t, err) + require.False(t, resp.IsError) + + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) + return result +} + +func listTemplateItems(t *testing.T, result map[string]any) []map[string]any { + t.Helper() + + rawTemplates, ok := result["templates"].([]any) + require.True(t, ok) + templates := make([]map[string]any, 0, len(rawTemplates)) + for _, raw := range rawTemplates { + template, ok := raw.(map[string]any) + require.True(t, ok) + templates = append(templates, template) + } + return templates +} diff --git a/coderd/x/chatd/chattool/readtemplate.go b/coderd/x/chatd/chattool/readtemplate.go index 09179237ba..984f0f5ba0 100644 --- a/coderd/x/chatd/chattool/readtemplate.go +++ b/coderd/x/chatd/chattool/readtemplate.go @@ -23,16 +23,17 @@ type readTemplateArgs struct { } // ReadTemplate returns a tool that retrieves details about a specific -// template, including its configurable rich parameters. The agent -// uses this after list_templates and before create_workspace. +// template, including its configurable rich parameters. The agent uses +// this after list_templates when it needs parameters or presets before +// create_workspace. // db must not be nil. func ReadTemplate(db database.Store, organizationID uuid.UUID, options ReadTemplateOptions) fantasy.AgentTool { return fantasy.NewAgentTool( "read_template", "Get details about a workspace template, including its "+ "configurable parameters and available presets. Use this "+ - "after finding a template with list_templates and before "+ - "creating a workspace with create_workspace.", + "after list_templates when you need required parameter "+ + "details or preset IDs before create_workspace.", func(ctx context.Context, args readTemplateArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { templateIDStr := strings.TrimSpace(args.TemplateID) if templateIDStr == "" { diff --git a/coderd/x/chatd/prompt.go b/coderd/x/chatd/prompt.go index 178db12919..76b6940448 100644 --- a/coderd/x/chatd/prompt.go +++ b/coderd/x/chatd/prompt.go @@ -1,5 +1,7 @@ package chatd +import "github.com/coder/coder/v2/coderd/x/chatd/chattool" + const defaultSystemPromptPlanPathBlockPlaceholder = "{{CODER_CHAT_PLAN_FILE_PATH_BLOCK}}" const workspaceAttachedAwareness = "This chat is attached to a workspace. You can use workspace tools like execute, read_file, write_file, etc." @@ -9,7 +11,7 @@ Do not create or start a workspace by default. Many requests can be completed us Workspace tools such as execute, read_file, write_file, and edit_files require an attached workspace.` const workspaceDetachedAwareness = workspaceDetachedAwarenessBase + ` Only call create_workspace or start_workspace when the user explicitly asks for a workspace-backed task, or when the task cannot be completed without inspecting, editing, or running files in a workspace. -If a workspace is needed, use list_templates and read_template as needed before create_workspace.` +If a workspace is needed, use list_templates before create_workspace and follow its ` + chattool.NextStepField + `. Call read_template only when you need template parameter or preset details.` const workspaceDetachedNoCreateAwareness = workspaceDetachedAwarenessBase + ` This delegated chat cannot create or start a workspace. If workspace-backed work is required, report that need to the parent agent instead of trying workspace tools.` @@ -104,6 +106,12 @@ Do not start with clarifying questions if the codebase or tools can answer them. Ask the minimum number of questions needed to define the scope together. + +When no workspace is attached and you need to create one: +- Call list_templates with concise search terms from the user's task, then follow its ` + chattool.NextStepField + `: use the recommended template, or ask the user to choose when none is recommended. +- Call read_template only when you need parameter or preset details before create_workspace. + + Propose a plan when: - The task is too ambiguous to implement with confidence. diff --git a/docs/ai-coder/agents/tools/index.md b/docs/ai-coder/agents/tools/index.md new file mode 100644 index 0000000000..f4eab0cba7 --- /dev/null +++ b/docs/ai-coder/agents/tools/index.md @@ -0,0 +1,217 @@ +# Tools + +Coder Agents completes work by calling tools: structured functions the agent +invokes during a chat to gather context and take action, such as listing +templates, reading files, running commands, or creating a workspace. + +This page explains how tool calls work and documents the tools the agent uses +to select a template and create a workspace. + +## How tool calls work + +Each turn of a conversation follows the same loop: + +1. You send a message. +2. The model decides whether it needs a tool and calls it with structured + arguments. For example, `list_templates` with `{"query": "docker"}`. +3. Coder executes the tool in the control plane using your identity and + permissions, and returns a JSON result to the model. +4. The model uses the result to decide what to do next: call another tool or + reply to you. + +Tool calls and their results are visible in the chat timeline, so you can +always inspect what the agent did and why. + +Two properties hold for every built-in tool: + +- **Your permissions apply.** Tools run with the chat owner's RBAC identity. + The agent cannot list templates or create workspaces that you could not + access yourself. +- **Results carry instructions.** Where a decision matters, the tool result + includes a `next_step` field containing a fixed instruction for the agent, + such as asking you to choose between similar templates. This keeps agent + behavior consistent without relying on long prompt rules. + +## Workspace creation tools + +A chat starts without a workspace, and many requests are answered without +one. When the agent needs compute (to read files, run commands, or edit +code), it provisions a workspace using three tools: + +| Tool | Purpose | +|--------------------|-----------------------------------------------------------| +| `list_templates` | Rank available templates and recommend one when confident | +| `read_template` | Read a template's parameters and presets | +| `create_workspace` | Create the workspace from a chosen template | + +Administrators can restrict which templates these tools can see with the +[template allowlist](../platform-controls/template-optimization.md#restrict-available-templates). + +### list_templates + +`list_templates` returns a ranked shortlist of the active, non-deprecated +templates in the chat's organization, so the agent can pick a template the +same way a colleague would: prefer what matches the request, what you already +use, and what the rest of the organization uses. + +#### Arguments + +| Argument | Type | Description | +|----------|---------|-------------------------------------------------------------------------------| +| `query` | string | Optional text matched against template names, display names, and descriptions | +| `page` | integer | Optional page number, starting at 1. Each page holds 10 templates | + +#### How templates are ranked + +Templates are ordered by query relevance first (when a query is provided), +then by an affinity score computed from your recent usage and organization +popularity. Name and ID break any remaining ties so the order is stable. + +##### Query relevance tiers + +When a query is provided, each template receives the highest tier that any +of its fields matches: + +| Tier | Match | +|------|-----------------------------------------------------| +| 4 | The name or display name equals the query | +| 3 | The name or display name starts with the query | +| 2 | The name or display name contains the query | +| 1 | The description contains the query | +| 0 | No match. The template is excluded from the results | + +The description is checked only when neither the name nor the display name +matched. Matching is case-insensitive and ignores spaces, hyphens, and +underscores, so `python gpu` matches `python-gpu`. A higher tier always +outranks a lower tier, regardless of usage. + +##### Affinity score + +Within a relevance tier, or when no query is given, templates are ordered by +an affinity score: + +```text +affinity = 10 x (active + 0.5 x deleted) x 0.5^(days_since_last_use / 14) + + ln(1 + active_developers) +``` + +- `active`: your workspaces on the template used in the last 60 days. +- `deleted`: your recently deleted workspaces used in the last 60 days, + counted at half the weight of active ones. +- `days_since_last_use`: days since you last used the template. The personal + term halves every 14 days and is zero when you have no usage in the last + 60 days. +- `active_developers`: developers in the organization with an active + workspace on the template, excluding unclaimed prebuilt workspaces. + +Personal usage carries ten times the weight of organization popularity, and +popularity grows logarithmically so heavily used templates do not drown out +your own history. Each template is scored independently from its own +signals; one template's usage never affects another's score. The score is +internal and only determines ordering and the recommendation. The response +carries the raw evidence instead: `active_developers`, +`your_workspace_count`, and `last_used_by_you` appear on each template when +they are non-zero. + +#### Recommendation and next_step + +Beyond the ranked list, the result tells the agent what to do next: + +- `recommended_template_id` is present only when the top template is a clear + winner: it is the only available template, it matches the query at a + strictly higher tier than every other match, or its affinity score reaches + at least the score of two active developers and leads the runner-up by a + clear margin. +- `next_step` is always present and contains one of four fixed instructions: + +| Situation | `next_step` instruction for the agent | +|-------------------------------------|-------------------------------------------------------| +| A template is recommended | Use `recommended_template_id` with `create_workspace` | +| Top templates are too close to call | Ask you to choose a template | +| The query matched nothing | Retry without a query or ask you | +| No templates are available | Inform you that no templates are available | + +When the agent is told to ask, it presents the choices instead of guessing. +You can always override the recommendation by naming a template yourself. + +#### Example + +A user asks for "a workspace to debug a Docker image". The agent extracts a +search term and calls `list_templates` with: + +```json +{ + "query": "docker" +} +``` + +The response lists matching templates best-first. Here `docker` is an exact +name match while `docker-gpu` is only a prefix match, so the top template is +a clear winner and is recommended: + +```json +{ + "templates": [ + { + "id": "0f9ab36e-43f6-4d8a-b3e6-6803d9a06f72", + "name": "docker", + "display_name": "Docker", + "description": "Provision Docker containers as Coder workspaces.", + "active_developers": 14, + "your_workspace_count": 2, + "last_used_by_you": "2026-06-09T10:04:18.123456Z" + }, + { + "id": "8d2cf2a1-55b0-4c4e-9a3f-41be8a5cd1f0", + "name": "docker-gpu", + "display_name": "Docker GPU", + "description": "Docker workspaces with NVIDIA GPU access.", + "active_developers": 3 + } + ], + "page": 1, + "recommended_template_id": "0f9ab36e-43f6-4d8a-b3e6-6803d9a06f72", + "next_step": "Use recommended_template_id with create_workspace. Call read_template first only if you need parameter or preset details." +} +``` + +The agent follows `next_step` and calls `create_workspace` with the +recommended template. Had the two templates tied (for example, both plain +substring matches with similar usage), the response would omit +`recommended_template_id` and `next_step` would instruct the agent to ask +you to choose. + +`next_page` is present only when more results exist. + +### read_template + +`read_template` reads one template's details: its configurable parameters +(name, type, default, options, and validation rules) and its presets, +including preset parameter values and whether prebuilt workspaces are +available for faster startup. + +The agent calls it only when needed, typically when a template has required +parameters or when a preset should be applied. For templates that work with +defaults, the agent skips straight to `create_workspace`. + +### create_workspace + +`create_workspace` provisions a workspace from a template and waits for it to +become ready, then attaches it to the chat. + +| Argument | Type | Description | +|---------------|--------|------------------------------------------------------------------------------------| +| `template_id` | string | Required. The template UUID from `list_templates` | +| `name` | string | Optional workspace name. One is generated if omitted | +| `parameters` | object | Optional template parameter values from `read_template` | +| `preset_id` | string | Optional preset UUID. Applies preset parameters and may claim a prebuilt workspace | + +Guardrails: + +- The agent is instructed to create a workspace only when the task requires + one or when you explicitly ask for it, and to follow the `next_step` from + `list_templates`, which means asking you first when no template was + recommended. +- The tool is idempotent: if the chat already has a workspace building or + running, that workspace is returned instead of creating a duplicate. +- Templates outside the administrator's allowlist are rejected. diff --git a/docs/manifest.json b/docs/manifest.json index 70640e7c96..b679fed7fa 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1024,6 +1024,12 @@ "path": "./ai-coder/agents/models.md", "state": ["beta"] }, + { + "title": "Tools", + "description": "How Coder Agents uses tool calls to select templates and create workspaces", + "path": "./ai-coder/agents/tools/index.md", + "state": ["beta"] + }, { "title": "Platform Controls", "description": "How platform teams control agent behavior, models, and policies",