feat(coderd): generate task names based on their prompt (#19335)

Closes https://github.com/coder/coder/issues/18159

If an Anthropic API key is available, we call out to Claude to generate
a task name based on the user-provided prompt instead of our random name
generator.
This commit is contained in:
Danielle Maywood
2025-08-19 14:56:37 +01:00
committed by GitHub
parent c4290201c3
commit 655377165b
4 changed files with 210 additions and 2 deletions
+16 -1
View File
@@ -10,11 +10,14 @@ import (
"github.com/google/uuid"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/taskname"
"github.com/coder/coder/v2/codersdk"
)
@@ -104,8 +107,20 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) {
return
}
taskName := req.Name
if anthropicAPIKey := taskname.GetAnthropicAPIKeyFromEnv(); anthropicAPIKey != "" {
anthropicModel := taskname.GetAnthropicModelFromEnv()
generatedName, err := taskname.Generate(ctx, req.Prompt, taskname.WithAPIKey(anthropicAPIKey), taskname.WithModel(anthropicModel))
if err != nil {
api.Logger.Error(ctx, "unable to generate task name", slog.Error(err))
} else {
taskName = generatedName
}
}
createReq := codersdk.CreateWorkspaceRequest{
Name: req.Name,
Name: taskName,
TemplateVersionID: req.TemplateVersionID,
TemplateVersionPresetID: req.TemplateVersionPresetID,
RichParameterValues: []codersdk.WorkspaceBuildParameter{
+145
View File
@@ -0,0 +1,145 @@
package taskname
import (
"context"
"io"
"os"
"github.com/anthropics/anthropic-sdk-go"
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
"golang.org/x/xerrors"
"github.com/coder/aisdk-go"
"github.com/coder/coder/v2/codersdk"
)
const (
defaultModel = anthropic.ModelClaude3_5HaikuLatest
systemPrompt = `Generate a short workspace name from this AI task prompt.
Requirements:
- Only lowercase letters, numbers, and hyphens
- Start with "task-"
- End with a random number between 0-99
- Maximum 32 characters total
- Descriptive of the main task
Examples:
- "Help me debug a Python script" → "task-python-debug-12"
- "Create a React dashboard component" → "task-react-dashboard-93"
- "Analyze sales data from Q3" → "task-analyze-q3-sales-37"
- "Set up CI/CD pipeline" → "task-setup-cicd-44"
If you cannot create a suitable name:
- Respond with "task-unnamed"
- Do not end with a random number`
)
var (
ErrNoAPIKey = xerrors.New("no api key provided")
ErrNoNameGenerated = xerrors.New("no task name generated")
)
type options struct {
apiKey string
model anthropic.Model
}
type Option func(o *options)
func WithAPIKey(apiKey string) Option {
return func(o *options) {
o.apiKey = apiKey
}
}
func WithModel(model anthropic.Model) Option {
return func(o *options) {
o.model = model
}
}
func GetAnthropicAPIKeyFromEnv() string {
return os.Getenv("ANTHROPIC_API_KEY")
}
func GetAnthropicModelFromEnv() anthropic.Model {
return anthropic.Model(os.Getenv("ANTHROPIC_MODEL"))
}
func Generate(ctx context.Context, prompt string, opts ...Option) (string, error) {
o := options{}
for _, opt := range opts {
opt(&o)
}
if o.model == "" {
o.model = defaultModel
}
if o.apiKey == "" {
return "", ErrNoAPIKey
}
conversation := []aisdk.Message{
{
Role: "system",
Parts: []aisdk.Part{{
Type: aisdk.PartTypeText,
Text: systemPrompt,
}},
},
{
Role: "user",
Parts: []aisdk.Part{{
Type: aisdk.PartTypeText,
Text: prompt,
}},
},
}
anthropicOptions := anthropic.DefaultClientOptions()
anthropicOptions = append(anthropicOptions, anthropicoption.WithAPIKey(o.apiKey))
anthropicClient := anthropic.NewClient(anthropicOptions...)
stream, err := anthropicDataStream(ctx, anthropicClient, o.model, conversation)
if err != nil {
return "", xerrors.Errorf("create anthropic data stream: %w", err)
}
var acc aisdk.DataStreamAccumulator
stream = stream.WithAccumulator(&acc)
if err := stream.Pipe(io.Discard); err != nil {
return "", xerrors.Errorf("pipe data stream")
}
if len(acc.Messages()) == 0 {
return "", ErrNoNameGenerated
}
generatedName := acc.Messages()[0].Content
if err := codersdk.NameValid(generatedName); err != nil {
return "", xerrors.Errorf("generated name %v not valid: %w", generatedName, err)
}
if generatedName == "task-unnamed" {
return "", ErrNoNameGenerated
}
return generatedName, nil
}
func anthropicDataStream(ctx context.Context, client anthropic.Client, model anthropic.Model, input []aisdk.Message) (aisdk.DataStream, error) {
messages, system, err := aisdk.MessagesToAnthropic(input)
if err != nil {
return nil, xerrors.Errorf("convert messages to anthropic format: %w", err)
}
return aisdk.AnthropicToDataStream(client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{
Model: model,
MaxTokens: 24,
System: system,
Messages: messages,
})), nil
}
+48
View File
@@ -0,0 +1,48 @@
package taskname_test
import (
"os"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/taskname"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
const (
anthropicEnvVar = "ANTHROPIC_API_KEY"
)
func TestGenerateTaskName(t *testing.T) {
t.Parallel()
t.Run("Fallback", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
name, err := taskname.Generate(ctx, "Some random prompt")
require.ErrorIs(t, err, taskname.ErrNoAPIKey)
require.Equal(t, "", name)
})
t.Run("Anthropic", func(t *testing.T) {
t.Parallel()
apiKey := os.Getenv(anthropicEnvVar)
if apiKey == "" {
t.Skipf("Skipping test as %s not set", anthropicEnvVar)
}
ctx := testutil.Context(t, testutil.WaitShort)
name, err := taskname.Generate(ctx, "Create a finance planning app", taskname.WithAPIKey(apiKey))
require.NoError(t, err)
require.NotEqual(t, "", name)
err = codersdk.NameValid(name)
require.NoError(t, err, "name should be valid")
})
}