mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
+16
-1
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user