diff --git a/coderd/aitasks.go b/coderd/aitasks.go index e1d72f264a025..f5d72beaf3903 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -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{ diff --git a/coderd/taskname/taskname.go b/coderd/taskname/taskname.go new file mode 100644 index 0000000000000..970e5ad67b2a0 --- /dev/null +++ b/coderd/taskname/taskname.go @@ -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 +} diff --git a/coderd/taskname/taskname_test.go b/coderd/taskname/taskname_test.go new file mode 100644 index 0000000000000..0737621b8f4eb --- /dev/null +++ b/coderd/taskname/taskname_test.go @@ -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") + }) +} diff --git a/go.mod b/go.mod index e10c7a248db7e..6d703cdd1245e 100644 --- a/go.mod +++ b/go.mod @@ -477,6 +477,7 @@ require ( ) require ( + github.com/anthropics/anthropic-sdk-go v1.4.0 github.com/brianvoe/gofakeit/v7 v7.3.0 github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 github.com/coder/aisdk-go v0.0.9 @@ -500,7 +501,6 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0 // indirect github.com/Masterminds/semver/v3 v3.3.1 // indirect - github.com/anthropics/anthropic-sdk-go v1.4.0 // indirect github.com/aquasecurity/go-version v0.0.1 // indirect github.com/aquasecurity/trivy v0.58.2 // indirect github.com/aws/aws-sdk-go v1.55.7 // indirect