-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Expand file tree
/
Copy pathtaskname.go
More file actions
351 lines (297 loc) · 11.3 KB
/
taskname.go
File metadata and controls
351 lines (297 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
package taskname
import (
"context"
"encoding/json"
"fmt"
"io"
"math/rand/v2"
"os"
"regexp"
"strings"
"github.com/anthropics/anthropic-sdk-go"
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/aisdk-go"
"github.com/coder/coder/v2/coderd/util/namesgenerator"
strutil "github.com/coder/coder/v2/coderd/util/strings"
"github.com/coder/coder/v2/codersdk"
)
const (
defaultModel = anthropic.ModelClaudeHaiku4_5
systemPrompt = `Generate a short task display name and name from this AI task prompt.
Identify the main task (the core action and subject) and base both names on it.
The task display name and name should be as similar as possible so a human can easily associate them.
Requirements for task display name (generate this first):
- Human-readable description
- Maximum 64 characters total
- Should concisely describe the main task
Requirements for task name:
- Should be derived from the display name
- Only lowercase letters, numbers, and hyphens
- No spaces or underscores
- Maximum 27 characters total
- Should concisely describe the main task
Output format (must be valid JSON):
{
"display_name": "<display_name>",
"task_name": "<task_name>"
}
Examples:
Prompt: "Help me debug a Python script" →
{
"display_name": "Debug Python script",
"task_name": "python-debug"
}
Prompt: "Create a React dashboard component" →
{
"display_name": "React dashboard component",
"task_name": "react-dashboard"
}
Prompt: "Analyze sales data from Q3" →
{
"display_name": "Analyze Q3 sales data",
"task_name": "analyze-q3-sales"
}
Prompt: "Set up CI/CD pipeline" →
{
"display_name": "CI/CD pipeline setup",
"task_name": "setup-cicd"
}
Prompt: "Work on https://github.com/coder/coder/issues/1234" →
{
"display_name": "Work on coder/coder #1234",
"task_name": "coder-1234"
}
Prompt: "Fix https://github.com/org/repo/pull/567" →
{
"display_name": "Fix org/repo PR #567",
"task_name": "repo-pr-567"
}
If a suitable name cannot be created, output exactly:
{
"display_name": "Task Unnamed",
"task_name": "task-unnamed"
}
Do not include any additional keys, explanations, or text outside the JSON.`
)
var (
ErrNoAPIKey = xerrors.New("no api key provided")
ErrNoNameGenerated = xerrors.New("no task name generated")
markdownCodeFenceRE = regexp.MustCompile("(?s)^```[^\n]*\n(.*?)(?:\n```.*|```\\s*)?$")
)
// extractJSON strips optional markdown code fences (```json or ```) that
// LLMs sometimes wrap around JSON output, returning only the inner JSON
// string. If the response starts with JSON, it returns the first JSON value so
// trailing commentary or dangling fences do not break parsing.
func extractJSON(s string) string {
s = strings.TrimSpace(s)
if matches := markdownCodeFenceRE.FindStringSubmatch(s); matches != nil {
s = strings.TrimSpace(matches[1])
}
var raw json.RawMessage
if err := json.NewDecoder(strings.NewReader(s)).Decode(&raw); err == nil {
return string(raw)
}
return s
}
type TaskName struct {
Name string `json:"task_name"`
DisplayName string `json:"display_name"`
}
func getAnthropicAPIKeyFromEnv() string {
return os.Getenv("ANTHROPIC_API_KEY")
}
func getAnthropicModelFromEnv() anthropic.Model {
return anthropic.Model(os.Getenv("ANTHROPIC_MODEL"))
}
// generateSuffix generates a random hex string between `0000` and `ffff`.
func generateSuffix() string {
numMin := 0x00000
numMax := 0x10000
//nolint:gosec // We don't need a cryptographically secure random number generator for generating a task name suffix.
num := rand.IntN(numMax-numMin) + numMin
return fmt.Sprintf("%04x", num)
}
// generateFallback generates a random task name when other methods fail.
// Uses Docker-style name generation with a collision-resistant suffix.
func generateFallback() TaskName {
// We have a 32 character limit for the name.
// We have a 5 character suffix `-ffff`.
// This leaves us with 27 characters for the name.
name := namesgenerator.NameWith("-")
name = name[:min(len(name), 27)]
name = strings.TrimSuffix(name, "-")
taskName := fmt.Sprintf("%s-%s", name, generateSuffix())
displayName := strings.ReplaceAll(name, "-", " ")
if len(displayName) > 0 {
displayName = strings.ToUpper(displayName[:1]) + displayName[1:]
}
return TaskName{
Name: taskName,
DisplayName: displayName,
}
}
// generateFromPrompt creates a task name directly from the prompt by sanitizing it.
// This is used as a fallback when Claude fails to generate a name.
func generateFromPrompt(prompt string) (TaskName, error) {
// Normalize newlines and tabs to spaces
prompt = regexp.MustCompile(`[\n\r\t]+`).ReplaceAllString(prompt, " ")
// Truncate prompt to 27 chars with full words for task name generation
truncatedForName := prompt
if len(prompt) > 27 {
truncatedForName = strutil.Truncate(prompt, 27, strutil.TruncateWithFullWords)
}
// Generate task name from truncated prompt
name := strings.ToLower(truncatedForName)
// Replace whitespace (\t \r \n and spaces) sequences with hyphens
name = regexp.MustCompile(`\s+`).ReplaceAllString(name, "-")
// Remove all characters except lowercase letters, numbers, and hyphens
name = regexp.MustCompile(`[^a-z0-9-]+`).ReplaceAllString(name, "")
// Collapse multiple consecutive hyphens into a single hyphen
name = regexp.MustCompile(`-+`).ReplaceAllString(name, "-")
// Remove leading and trailing hyphens
name = strings.Trim(name, "-")
if len(name) == 0 {
return TaskName{}, ErrNoNameGenerated
}
taskName := fmt.Sprintf("%s-%s", name, generateSuffix())
// Use the initial prompt as display name, truncated to 64 chars with full words
displayName := strutil.Truncate(prompt, 64, strutil.TruncateWithFullWords, strutil.TruncateWithEllipsis)
displayName = strings.TrimSpace(displayName)
if len(displayName) == 0 {
// Ensure display name is never empty
displayName = strings.ReplaceAll(name, "-", " ")
}
displayName = strutil.Capitalize(displayName)
return TaskName{
Name: taskName,
DisplayName: displayName,
}, nil
}
// generateFromAnthropic uses Claude (Anthropic) to generate semantic task and display names from a user prompt.
// It sends the prompt to Claude with a structured system prompt requesting JSON output containing both names.
// Returns an error if the API call fails, the response is invalid, or Claude returns an "unnamed" placeholder.
func generateFromAnthropic(ctx context.Context, prompt string, apiKey string, model anthropic.Model, opts ...anthropicoption.RequestOption) (TaskName, error) {
anthropicModel := model
if anthropicModel == "" {
anthropicModel = defaultModel
}
if apiKey == "" {
return TaskName{}, 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(apiKey))
anthropicOptions = append(anthropicOptions, opts...)
anthropicClient := anthropic.NewClient(anthropicOptions...)
stream, err := anthropicDataStream(ctx, anthropicClient, anthropicModel, conversation)
if err != nil {
return TaskName{}, 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 TaskName{}, xerrors.Errorf("pipe data stream")
}
if len(acc.Messages()) == 0 {
return TaskName{}, ErrNoNameGenerated
}
// Parse the JSON response. LLMs sometimes wrap JSON in
// markdown code fences (```json ... ```), so we strip
// those before unmarshalling.
var taskNameResponse TaskName
if err := json.Unmarshal([]byte(extractJSON(acc.Messages()[0].Content)), &taskNameResponse); err != nil {
return TaskName{}, xerrors.Errorf("failed to parse anthropic response: %w", err)
}
taskNameResponse.Name = strings.TrimSpace(taskNameResponse.Name)
taskNameResponse.DisplayName = strings.TrimSpace(taskNameResponse.DisplayName)
if taskNameResponse.Name == "" || taskNameResponse.Name == "task-unnamed" {
return TaskName{}, xerrors.Errorf("anthropic returned invalid task name: %q", taskNameResponse.Name)
}
if taskNameResponse.DisplayName == "" || taskNameResponse.DisplayName == "Task Unnamed" {
return TaskName{}, xerrors.Errorf("anthropic returned invalid task display name: %q", taskNameResponse.DisplayName)
}
// We append a suffix to the end of the task name to reduce
// the chance of collisions. We truncate the task name to
// a maximum of 27 bytes, so that when we append the
// 5 byte suffix (`-` and 4 byte hex slug), it should
// remain within the 32 byte workspace name limit.
name := taskNameResponse.Name[:min(len(taskNameResponse.Name), 27)]
name = strings.TrimSuffix(name, "-")
name = fmt.Sprintf("%s-%s", name, generateSuffix())
if err := codersdk.NameValid(name); err != nil {
return TaskName{}, xerrors.Errorf("generated name %v not valid: %w", name, err)
}
displayName := taskNameResponse.DisplayName
displayName = strings.TrimSpace(displayName)
if len(displayName) == 0 {
// Ensure display name is never empty
displayName = strings.ReplaceAll(taskNameResponse.Name, "-", " ")
}
displayName = strutil.Capitalize(displayName)
return TaskName{
Name: name,
DisplayName: displayName,
}, nil
}
// Generate creates a task name and display name from a user prompt.
// It attempts multiple strategies in order of preference:
// 1. Use Claude (Anthropic) to generate semantic names from the prompt if an API key is available
// 2. Sanitize the prompt directly into a valid task name
// 3. Generate a random name as a final fallback
//
// A suffix is always appended to task names to reduce collision risk.
// This function always succeeds and returns a valid TaskName.
func Generate(ctx context.Context, logger slog.Logger, prompt string) TaskName {
if anthropicAPIKey := getAnthropicAPIKeyFromEnv(); anthropicAPIKey != "" {
taskName, err := generateFromAnthropic(ctx, prompt, anthropicAPIKey, getAnthropicModelFromEnv())
if err == nil {
return taskName
}
// Anthropic failed, fall through to next fallback
logger.Error(ctx, "unable to generate task name and display name from Anthropic", slog.Error(err))
}
// Try generating from prompt
taskName, err := generateFromPrompt(prompt)
if err == nil {
return taskName
}
logger.Warn(ctx, "unable to generate task name and display name from prompt", slog.Error(err))
// Final fallback
return generateFallback()
}
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 is set to 100 based on the maximum expected output size.
// The worst-case JSON output is 134 characters:
// - Base structure: 43 chars (including formatting)
// - task_name: 27 chars max
// - display_name: 64 chars max
// Using Anthropic's token counting API, this worst-case output tokenizes to 70 tokens.
// We set MaxTokens to 100 to provide a safety buffer.
MaxTokens: 100,
System: system,
Messages: messages,
})), nil
}