diff --git a/coderd/aitasks.go b/coderd/aitasks.go index e1d72f264a025..7bb2d6bf900f3 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,13 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { return } + taskName, err := taskname.Generate(ctx, req.Prompt, req.Name) + if err != nil { + api.Logger.Error(ctx, "unable to generate task name", slog.Error(err)) + } + 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..b22322cc8e8f6 --- /dev/null +++ b/coderd/taskname/taskname.go @@ -0,0 +1,99 @@ +package taskname + +import ( + "context" + "io" + "os" + + "github.com/anthropics/anthropic-sdk-go" + "golang.org/x/xerrors" + + "github.com/coder/aisdk-go" + "github.com/coder/coder/v2/codersdk" +) + +const 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-workspace" +- Do not end with a random number` + +func Generate(ctx context.Context, prompt, fallback string) (string, error) { + conversation := []aisdk.Message{ + { + Role: "system", + Parts: []aisdk.Part{{ + Type: aisdk.PartTypeText, + Text: systemPrompt, + }}, + }, + { + Role: "user", + Parts: []aisdk.Part{{ + Type: aisdk.PartTypeText, + Text: prompt, + }}, + }, + } + + if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey == "" { + return fallback, nil + } + + anthropicClient := anthropic.NewClient(anthropic.DefaultClientOptions()...) + + stream, err := anthropicDataStream(ctx, anthropicClient, conversation) + if err != nil { + return fallback, 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 fallback, xerrors.Errorf("pipe data stream") + } + + if len(acc.Messages()) == 0 { + return fallback, nil + } + + generatedName := acc.Messages()[0].Content + + if err := codersdk.NameValid(generatedName); err != nil { + return fallback, xerrors.Errorf("generated name %v not valid: %w", generatedName, err) + } + + if generatedName == "task-workspace" { + return fallback, nil + } + + return generatedName, nil +} + +func anthropicDataStream(ctx context.Context, client anthropic.Client, 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: anthropic.ModelClaude3_5HaikuLatest, + 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..3b66f4fad0af7 --- /dev/null +++ b/coderd/taskname/taskname_test.go @@ -0,0 +1,50 @@ +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" +) + +//nolint:paralleltest // test modifies env variables +func TestGenerateTaskName(t *testing.T) { + t.Run("Fallback", func(t *testing.T) { + if apiKey := os.Getenv(anthropicEnvVar); apiKey != "" { + os.Setenv(anthropicEnvVar, "") + + t.Cleanup(func() { + os.Setenv(anthropicEnvVar, apiKey) + }) + } + + ctx := testutil.Context(t, testutil.WaitShort) + + name, err := taskname.Generate(ctx, "Some random prompt", "task-fallback") + require.NoError(t, err) + require.Equal(t, "task-fallback", name) + }) + + t.Run("Anthropic", func(t *testing.T) { + if apiKey := os.Getenv(anthropicEnvVar); 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`, "task-fallback") + require.NoError(t, err) + require.NotEqual(t, "task-fallback", 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