From afd1c7092f1fc2809ae7a23818894bc3adecaaf0 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 12 Aug 2025 16:26:26 +0000 Subject: [PATCH 1/2] feat(coderd): generate task name based on prompt using llm Generate the name of a task by querying an LLM --- coderd/aitasks.go | 84 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index e1d72f264a025..60fe19021c1c1 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -1,15 +1,21 @@ package coderd import ( + "context" "database/sql" "errors" "fmt" + "io" "net/http" + "os" "slices" "strings" + "github.com/anthropics/anthropic-sdk-go" + anthropicoption "github.com/anthropics/anthropic-sdk-go/option" "github.com/google/uuid" + "github.com/coder/aisdk-go" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/httpapi" @@ -69,6 +75,69 @@ func (api *API) aiTasksPrompts(rw http.ResponseWriter, r *http.Request) { }) } +func (api *API) generateTaskName(ctx context.Context, prompt, fallback string) (string, error) { + // TODO(DanielleMaywood): + // Should we extract this out into our typical coderd option handling? + anthropicAPIKey := os.Getenv("ANTHROPIC_API_KEY") + + // The deployment doesn't have a valid external cloud AI provider, so we'll + // fallback to the user supplied name for now. + if anthropicAPIKey == "" { + return fallback, nil + } + + anthropicClient := anthropic.NewClient(anthropicoption.WithAPIKey(anthropicAPIKey)) + + messages, system, err := aisdk.MessagesToAnthropic([]aisdk.Message{ + { + Role: "system", + Parts: []aisdk.Part{{ + Type: aisdk.PartTypeText, + Text: ` + You are a task summarizer. + You summarize AI prompts into workspace names. + You will only respond with a workspace name. + The workspace name **MUST** follow this regex /^[a-z0-9]+(?:-[a-z0-9]+)*$/ + The workspace name **MUST** be 32 characters or **LESS**. + The workspace name **MUST** be all lower case. + The workspace name **MUST** end in a number between 0 and 100. + The workspace name **MUST** be prefixed with "task". + `, + }}, + }, + { + Role: "user", + Parts: []aisdk.Part{{ + Type: aisdk.PartTypeText, + Text: prompt, + }}, + }, + }) + if err != nil { + return "", err + } + + stream := aisdk.AnthropicToDataStream(anthropicClient.Messages.NewStreaming(ctx, anthropic.MessageNewParams{ + Model: anthropic.ModelClaude3_5HaikuLatest, + Messages: messages, + System: system, + MaxTokens: 24, + })) + + var acc aisdk.DataStreamAccumulator + stream = stream.WithAccumulator(&acc) + + if err := stream.Pipe(io.Discard); err != nil { + return "", err + } + + if len(acc.Messages()) == 0 { + return fallback, nil + } + + return acc.Messages()[0].Content, nil +} + // This endpoint is experimental and not guaranteed to be stable, so we're not // generating public-facing documentation for it. func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { @@ -104,8 +173,21 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { return } + taskName, err := api.generateTaskName(ctx, req.Prompt, req.Name) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error generating name for task.", + Detail: err.Error(), + }) + return + } + + if taskName == "" { + taskName = req.Name + } + createReq := codersdk.CreateWorkspaceRequest{ - Name: req.Name, + Name: taskName, TemplateVersionID: req.TemplateVersionID, TemplateVersionPresetID: req.TemplateVersionPresetID, RichParameterValues: []codersdk.WorkspaceBuildParameter{ From 1ed234e91ec97a0b7dbbd914dda377e89841cdb8 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 12 Aug 2025 16:41:55 +0000 Subject: [PATCH 2/2] refactor: slightly --- coderd/aitasks.go | 70 +++++++++++++++++++++++++++-------------------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 60fe19021c1c1..6b3f439e252ef 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -14,6 +14,7 @@ import ( "github.com/anthropics/anthropic-sdk-go" anthropicoption "github.com/anthropics/anthropic-sdk-go/option" "github.com/google/uuid" + "golang.org/x/xerrors" "github.com/coder/aisdk-go" "github.com/coder/coder/v2/coderd/audit" @@ -76,33 +77,26 @@ func (api *API) aiTasksPrompts(rw http.ResponseWriter, r *http.Request) { } func (api *API) generateTaskName(ctx context.Context, prompt, fallback string) (string, error) { - // TODO(DanielleMaywood): - // Should we extract this out into our typical coderd option handling? - anthropicAPIKey := os.Getenv("ANTHROPIC_API_KEY") - - // The deployment doesn't have a valid external cloud AI provider, so we'll - // fallback to the user supplied name for now. - if anthropicAPIKey == "" { - return fallback, nil - } - - anthropicClient := anthropic.NewClient(anthropicoption.WithAPIKey(anthropicAPIKey)) + var ( + stream aisdk.DataStream + err error + ) - messages, system, err := aisdk.MessagesToAnthropic([]aisdk.Message{ + conversation := []aisdk.Message{ { Role: "system", Parts: []aisdk.Part{{ Type: aisdk.PartTypeText, Text: ` - You are a task summarizer. - You summarize AI prompts into workspace names. - You will only respond with a workspace name. - The workspace name **MUST** follow this regex /^[a-z0-9]+(?:-[a-z0-9]+)*$/ - The workspace name **MUST** be 32 characters or **LESS**. - The workspace name **MUST** be all lower case. - The workspace name **MUST** end in a number between 0 and 100. - The workspace name **MUST** be prefixed with "task". - `, + You are a task summarizer. + You summarize AI prompts into workspace names. + You will only respond with a workspace name. + The workspace name **MUST** follow this regex /^[a-z0-9]+(?:-[a-z0-9]+)*$/ + The workspace name **MUST** be 32 characters or **LESS**. + The workspace name **MUST** be all lower case. + The workspace name **MUST** end in a number between 0 and 100. + The workspace name **MUST** be prefixed with "task". + `, }}, }, { @@ -112,17 +106,19 @@ func (api *API) generateTaskName(ctx context.Context, prompt, fallback string) ( Text: prompt, }}, }, - }) - if err != nil { - return "", err } - stream := aisdk.AnthropicToDataStream(anthropicClient.Messages.NewStreaming(ctx, anthropic.MessageNewParams{ - Model: anthropic.ModelClaude3_5HaikuLatest, - Messages: messages, - System: system, - MaxTokens: 24, - })) + if anthropicAPIKey := os.Getenv("ANTHROPIC_API_KEY"); anthropicAPIKey != "" { + anthropicAPIKey := os.Getenv("ANTHROPIC_API_KEY") + anthropicClient := anthropic.NewClient(anthropicoption.WithAPIKey(anthropicAPIKey)) + + stream, err = anthropicDataStream(ctx, anthropicClient, conversation) + if err != nil { + return "", xerrors.Errorf("create anthropic data stream: %w", err) + } + } else { + return fallback, nil + } var acc aisdk.DataStreamAccumulator stream = stream.WithAccumulator(&acc) @@ -138,6 +134,20 @@ func (api *API) generateTaskName(ctx context.Context, prompt, fallback string) ( return acc.Messages()[0].Content, 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 +} + // This endpoint is experimental and not guaranteed to be stable, so we're not // generating public-facing documentation for it. func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) {