Skip to content

Commit 6553771

Browse files
feat(coderd): generate task names based on their prompt (#19335)
Closes #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.
1 parent c429020 commit 6553771

File tree

4 files changed

+210
-2
lines changed

4 files changed

+210
-2
lines changed

coderd/aitasks.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ import (
1010

1111
"github.com/google/uuid"
1212

13+
"cdr.dev/slog"
14+
1315
"github.com/coder/coder/v2/coderd/audit"
1416
"github.com/coder/coder/v2/coderd/database"
1517
"github.com/coder/coder/v2/coderd/httpapi"
1618
"github.com/coder/coder/v2/coderd/httpmw"
1719
"github.com/coder/coder/v2/coderd/rbac"
20+
"github.com/coder/coder/v2/coderd/taskname"
1821
"github.com/coder/coder/v2/codersdk"
1922
)
2023

@@ -104,8 +107,20 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) {
104107
return
105108
}
106109

110+
taskName := req.Name
111+
if anthropicAPIKey := taskname.GetAnthropicAPIKeyFromEnv(); anthropicAPIKey != "" {
112+
anthropicModel := taskname.GetAnthropicModelFromEnv()
113+
114+
generatedName, err := taskname.Generate(ctx, req.Prompt, taskname.WithAPIKey(anthropicAPIKey), taskname.WithModel(anthropicModel))
115+
if err != nil {
116+
api.Logger.Error(ctx, "unable to generate task name", slog.Error(err))
117+
} else {
118+
taskName = generatedName
119+
}
120+
}
121+
107122
createReq := codersdk.CreateWorkspaceRequest{
108-
Name: req.Name,
123+
Name: taskName,
109124
TemplateVersionID: req.TemplateVersionID,
110125
TemplateVersionPresetID: req.TemplateVersionPresetID,
111126
RichParameterValues: []codersdk.WorkspaceBuildParameter{

coderd/taskname/taskname.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package taskname
2+
3+
import (
4+
"context"
5+
"io"
6+
"os"
7+
8+
"github.com/anthropics/anthropic-sdk-go"
9+
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
10+
"golang.org/x/xerrors"
11+
12+
"github.com/coder/aisdk-go"
13+
"github.com/coder/coder/v2/codersdk"
14+
)
15+
16+
const (
17+
defaultModel = anthropic.ModelClaude3_5HaikuLatest
18+
systemPrompt = `Generate a short workspace name from this AI task prompt.
19+
20+
Requirements:
21+
- Only lowercase letters, numbers, and hyphens
22+
- Start with "task-"
23+
- End with a random number between 0-99
24+
- Maximum 32 characters total
25+
- Descriptive of the main task
26+
27+
Examples:
28+
- "Help me debug a Python script" → "task-python-debug-12"
29+
- "Create a React dashboard component" → "task-react-dashboard-93"
30+
- "Analyze sales data from Q3" → "task-analyze-q3-sales-37"
31+
- "Set up CI/CD pipeline" → "task-setup-cicd-44"
32+
33+
If you cannot create a suitable name:
34+
- Respond with "task-unnamed"
35+
- Do not end with a random number`
36+
)
37+
38+
var (
39+
ErrNoAPIKey = xerrors.New("no api key provided")
40+
ErrNoNameGenerated = xerrors.New("no task name generated")
41+
)
42+
43+
type options struct {
44+
apiKey string
45+
model anthropic.Model
46+
}
47+
48+
type Option func(o *options)
49+
50+
func WithAPIKey(apiKey string) Option {
51+
return func(o *options) {
52+
o.apiKey = apiKey
53+
}
54+
}
55+
56+
func WithModel(model anthropic.Model) Option {
57+
return func(o *options) {
58+
o.model = model
59+
}
60+
}
61+
62+
func GetAnthropicAPIKeyFromEnv() string {
63+
return os.Getenv("ANTHROPIC_API_KEY")
64+
}
65+
66+
func GetAnthropicModelFromEnv() anthropic.Model {
67+
return anthropic.Model(os.Getenv("ANTHROPIC_MODEL"))
68+
}
69+
70+
func Generate(ctx context.Context, prompt string, opts ...Option) (string, error) {
71+
o := options{}
72+
for _, opt := range opts {
73+
opt(&o)
74+
}
75+
76+
if o.model == "" {
77+
o.model = defaultModel
78+
}
79+
if o.apiKey == "" {
80+
return "", ErrNoAPIKey
81+
}
82+
83+
conversation := []aisdk.Message{
84+
{
85+
Role: "system",
86+
Parts: []aisdk.Part{{
87+
Type: aisdk.PartTypeText,
88+
Text: systemPrompt,
89+
}},
90+
},
91+
{
92+
Role: "user",
93+
Parts: []aisdk.Part{{
94+
Type: aisdk.PartTypeText,
95+
Text: prompt,
96+
}},
97+
},
98+
}
99+
100+
anthropicOptions := anthropic.DefaultClientOptions()
101+
anthropicOptions = append(anthropicOptions, anthropicoption.WithAPIKey(o.apiKey))
102+
anthropicClient := anthropic.NewClient(anthropicOptions...)
103+
104+
stream, err := anthropicDataStream(ctx, anthropicClient, o.model, conversation)
105+
if err != nil {
106+
return "", xerrors.Errorf("create anthropic data stream: %w", err)
107+
}
108+
109+
var acc aisdk.DataStreamAccumulator
110+
stream = stream.WithAccumulator(&acc)
111+
112+
if err := stream.Pipe(io.Discard); err != nil {
113+
return "", xerrors.Errorf("pipe data stream")
114+
}
115+
116+
if len(acc.Messages()) == 0 {
117+
return "", ErrNoNameGenerated
118+
}
119+
120+
generatedName := acc.Messages()[0].Content
121+
122+
if err := codersdk.NameValid(generatedName); err != nil {
123+
return "", xerrors.Errorf("generated name %v not valid: %w", generatedName, err)
124+
}
125+
126+
if generatedName == "task-unnamed" {
127+
return "", ErrNoNameGenerated
128+
}
129+
130+
return generatedName, nil
131+
}
132+
133+
func anthropicDataStream(ctx context.Context, client anthropic.Client, model anthropic.Model, input []aisdk.Message) (aisdk.DataStream, error) {
134+
messages, system, err := aisdk.MessagesToAnthropic(input)
135+
if err != nil {
136+
return nil, xerrors.Errorf("convert messages to anthropic format: %w", err)
137+
}
138+
139+
return aisdk.AnthropicToDataStream(client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{
140+
Model: model,
141+
MaxTokens: 24,
142+
System: system,
143+
Messages: messages,
144+
})), nil
145+
}

coderd/taskname/taskname_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package taskname_test
2+
3+
import (
4+
"os"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/coder/coder/v2/coderd/taskname"
10+
"github.com/coder/coder/v2/codersdk"
11+
"github.com/coder/coder/v2/testutil"
12+
)
13+
14+
const (
15+
anthropicEnvVar = "ANTHROPIC_API_KEY"
16+
)
17+
18+
func TestGenerateTaskName(t *testing.T) {
19+
t.Parallel()
20+
21+
t.Run("Fallback", func(t *testing.T) {
22+
t.Parallel()
23+
24+
ctx := testutil.Context(t, testutil.WaitShort)
25+
26+
name, err := taskname.Generate(ctx, "Some random prompt")
27+
require.ErrorIs(t, err, taskname.ErrNoAPIKey)
28+
require.Equal(t, "", name)
29+
})
30+
31+
t.Run("Anthropic", func(t *testing.T) {
32+
t.Parallel()
33+
34+
apiKey := os.Getenv(anthropicEnvVar)
35+
if apiKey == "" {
36+
t.Skipf("Skipping test as %s not set", anthropicEnvVar)
37+
}
38+
39+
ctx := testutil.Context(t, testutil.WaitShort)
40+
41+
name, err := taskname.Generate(ctx, "Create a finance planning app", taskname.WithAPIKey(apiKey))
42+
require.NoError(t, err)
43+
require.NotEqual(t, "", name)
44+
45+
err = codersdk.NameValid(name)
46+
require.NoError(t, err, "name should be valid")
47+
})
48+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,7 @@ require (
477477
)
478478

479479
require (
480+
github.com/anthropics/anthropic-sdk-go v1.4.0
480481
github.com/brianvoe/gofakeit/v7 v7.3.0
481482
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225
482483
github.com/coder/aisdk-go v0.0.9
@@ -500,7 +501,6 @@ require (
500501
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0 // indirect
501502
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0 // indirect
502503
github.com/Masterminds/semver/v3 v3.3.1 // indirect
503-
github.com/anthropics/anthropic-sdk-go v1.4.0 // indirect
504504
github.com/aquasecurity/go-version v0.0.1 // indirect
505505
github.com/aquasecurity/trivy v0.58.2 // indirect
506506
github.com/aws/aws-sdk-go v1.55.7 // indirect

0 commit comments

Comments
 (0)