Skip to content

Commit c154af0

Browse files
Add automatic task title generation
Implement async title generation for tasks without descriptions using AI-generated summaries of conversation context. The system analyzes user messages and creates concise, descriptive titles (max 80 chars, 8 words). Key features: • Trigger generation when task lacks description but has user messages • Use singleflight to deduplicate concurrent generation attempts • Generate titles via budget model with iterative refinement • Retry up to 2 times if title exceeds length constraints • Store generated titles in Task.description field Co-authored-by: construct-agent <noreply@construct.sh>
1 parent 39bed74 commit c154af0

21 files changed

Lines changed: 556 additions & 24 deletions

.vscode/launch.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
"request": "launch",
3434
"mode": "auto",
3535
"program": "${workspaceFolder}/frontend/cli",
36-
"args": ["daemon", "run", "--listen-unix", "/tmp/construct.sock"],
36+
"args": ["daemon", "run", "--listen-unix", "/tmp/construct.sock", "--log-level", "debug"],
3737
"cwd": "${workspaceFolder}",
3838
"dlvLoadConfig": {
3939
"followPointers": true,

backend/agent/task_reconciler.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/google/uuid"
2828
"github.com/prometheus/client_golang/prometheus"
2929
"github.com/spf13/afero"
30+
"golang.org/x/sync/singleflight"
3031
"google.golang.org/protobuf/types/known/timestamppb"
3132
"k8s.io/client-go/util/workqueue"
3233
)
@@ -62,6 +63,7 @@ type TaskReconciler struct {
6263
providerFactory *ModelProviderFactory
6364
concurrency int
6465
runningTasks *SyncMap[uuid.UUID, context.CancelFunc]
66+
titleGenGroup singleflight.Group
6567
wg sync.WaitGroup
6668
}
6769

@@ -200,6 +202,11 @@ func (r *TaskReconciler) reconcile(ctx context.Context, taskID uuid.UUID) (Resul
200202
return Result{}, fmt.Errorf("failed to fetch messages: %w", err)
201203
}
202204

205+
// Trigger title generation if needed
206+
if shouldGenerateTitle(task, messages) {
207+
go r.generateTitleAsync(taskID)
208+
}
209+
203210
status, err := r.computeStatus(task, messages)
204211
if err != nil {
205212
return Result{}, fmt.Errorf("failed to compute status: %w", err)
@@ -745,3 +752,27 @@ func (r *TaskReconciler) setTaskPhaseAndPublish(ctx context.Context, taskID uuid
745752

746753
r.publishTaskEvent(taskID)
747754
}
755+
756+
func shouldGenerateTitle(task *memory.Task, messages []*memory.Message) bool {
757+
if task.Description != "" {
758+
return false
759+
}
760+
761+
if !hasUserMessage(messages) {
762+
return false
763+
}
764+
765+
return true
766+
}
767+
768+
func (r *TaskReconciler) generateTitleAsync(taskID uuid.UUID) {
769+
_, err, _ := r.titleGenGroup.Do(taskID.String(), func() (interface{}, error) {
770+
ctx := context.Background()
771+
generator := NewTitleGenerator(r.memory, r.providerFactory)
772+
return nil, generator.GenerateTitle(ctx, taskID)
773+
})
774+
775+
if err != nil {
776+
slog.Error("failed to generate title", "error", err, "task_id", taskID)
777+
}
778+
}

backend/agent/title_generator.go

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
package agent
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
"strings"
8+
9+
"github.com/furisto/construct/backend/memory"
10+
memory_message "github.com/furisto/construct/backend/memory/message"
11+
"github.com/furisto/construct/backend/memory/schema/types"
12+
memory_task "github.com/furisto/construct/backend/memory/task"
13+
"github.com/furisto/construct/backend/model"
14+
"github.com/google/uuid"
15+
)
16+
17+
const (
18+
MaxTitleLength = 80 // characters
19+
MaxRetries = 2 // attempts to get shorter title
20+
)
21+
22+
type TitleGenerator struct {
23+
memory *memory.Client
24+
providerFactory *ModelProviderFactory
25+
}
26+
27+
func NewTitleGenerator(memory *memory.Client, providerFactory *ModelProviderFactory) *TitleGenerator {
28+
return &TitleGenerator{
29+
memory: memory,
30+
providerFactory: providerFactory,
31+
}
32+
}
33+
34+
func (g *TitleGenerator) GenerateTitle(ctx context.Context, taskID uuid.UUID) error {
35+
_, agent, err := g.fetchTaskWithAgent(ctx, taskID)
36+
if err != nil {
37+
return fmt.Errorf("failed to fetch task: %w", err)
38+
}
39+
40+
messages, err := g.memory.Message.Query().
41+
Where(memory_message.TaskIDEQ(taskID)).
42+
Order(memory_message.ByCreateTime()).
43+
Limit(5).
44+
All(ctx)
45+
if err != nil {
46+
return fmt.Errorf("failed to fetch messages: %w", err)
47+
}
48+
49+
if !hasUserMessage(messages) {
50+
slog.DebugContext(ctx, "no user messages, skipping title generation", "task_id", taskID)
51+
return nil
52+
}
53+
54+
modelProvider, err := g.providerFactory.CreateClient(ctx, agent.Edges.Model.ModelProviderID)
55+
if err != nil {
56+
return fmt.Errorf("failed to create model provider: %w", err)
57+
}
58+
59+
modelMessages, err := g.buildMessageHistory(messages)
60+
if err != nil {
61+
return fmt.Errorf("failed to build message history: %w", err)
62+
}
63+
64+
systemPrompt := `You are a title generator for development tasks and conversations. Generate a concise, descriptive title based on the user's request and conversation context.
65+
66+
CRITICAL RULES:
67+
- Maximum 8 words
68+
- Maximum 80 characters
69+
- Start immediately with the title (no preamble)
70+
- Use action verbs when describing tasks
71+
- Be specific about technology/domain when relevant
72+
- NO quotes, markdown, or extra punctuation
73+
- NO meta-commentary or explanations
74+
75+
GOOD EXAMPLES:
76+
- "Implement JWT authentication for API"
77+
- "Fix memory leak in worker pool"
78+
- "Add TypeScript support to build"
79+
- "Debug race condition in cache"
80+
- "Refactor database connection pooling"
81+
82+
BAD EXAMPLES (too vague):
83+
- "Help with code"
84+
- "Fix issue"
85+
- "Question about app"
86+
87+
BAD EXAMPLES (too long):
88+
- "Implement a comprehensive user authentication system with JWT tokens and refresh functionality"
89+
90+
For the given conversation, generate ONLY the title starting with a quote:`
91+
92+
title, err := g.generateTitleWithRetry(ctx, modelProvider, systemPrompt, modelMessages, 0)
93+
if err != nil {
94+
return fmt.Errorf("failed to generate title: %w", err)
95+
}
96+
97+
_, err = memory.Transaction(ctx, g.memory, func(tx *memory.Client) (*memory.Task, error) {
98+
return tx.Task.UpdateOneID(taskID).SetDescription(title).Save(ctx)
99+
})
100+
if err != nil {
101+
return fmt.Errorf("failed to save title: %w", err)
102+
}
103+
104+
slog.InfoContext(ctx, "generated title for task", "task_id", taskID, "title", title)
105+
return nil
106+
}
107+
108+
func (g *TitleGenerator) generateTitleWithRetry(
109+
ctx context.Context,
110+
provider model.ModelProvider,
111+
systemPrompt string,
112+
messages []*model.Message,
113+
attempt int,
114+
) (string, error) {
115+
messagesWithPrefill := append(messages, &model.Message{
116+
Source: model.MessageSourceModel,
117+
Content: []model.ContentBlock{
118+
&model.TextBlock{Text: "\""},
119+
},
120+
})
121+
122+
var anthropicProvider *model.AnthropicProvider
123+
anthropicProvider, ok := provider.(*model.AnthropicProvider)
124+
if !ok {
125+
return "", fmt.Errorf("provider is not an Anthropic provider")
126+
}
127+
128+
budgetModel := anthropicProvider.BudgetModel()
129+
130+
response, err := provider.InvokeModel(
131+
ctx,
132+
budgetModel,
133+
systemPrompt,
134+
messagesWithPrefill,
135+
)
136+
if err != nil {
137+
return "", err
138+
}
139+
140+
title := extractTitle(response.Content)
141+
if title == "" {
142+
return "", fmt.Errorf("model returned empty title")
143+
}
144+
145+
if len(title) > MaxTitleLength && attempt < MaxRetries {
146+
slog.DebugContext(ctx, "title too long, retrying", "attempt", attempt+1, "length", len(title), "title", title)
147+
148+
messages = append(messages,
149+
&model.Message{
150+
Source: model.MessageSourceModel,
151+
Content: []model.ContentBlock{
152+
&model.TextBlock{Text: title},
153+
},
154+
},
155+
&model.Message{
156+
Source: model.MessageSourceUser,
157+
Content: []model.ContentBlock{
158+
&model.TextBlock{
159+
Text: "That title is too long. Please provide a shorter version (max 8 words, ~80 characters).",
160+
},
161+
},
162+
},
163+
)
164+
165+
return g.generateTitleWithRetry(ctx, provider, systemPrompt, messages, attempt+1)
166+
}
167+
168+
if len(title) > MaxTitleLength {
169+
slog.WarnContext(ctx, "title still too long after retries, truncating", "original_length", len(title))
170+
title = title[:MaxTitleLength-3] + "..."
171+
}
172+
173+
return title, nil
174+
}
175+
176+
func (g *TitleGenerator) fetchTaskWithAgent(ctx context.Context, taskID uuid.UUID) (*memory.Task, *memory.Agent, error) {
177+
task, err := g.memory.Task.Query().
178+
Where(memory_task.IDEQ(taskID)).
179+
WithAgent(func(query *memory.AgentQuery) {
180+
query.WithModel()
181+
}).
182+
Only(ctx)
183+
184+
if err != nil {
185+
return nil, nil, err
186+
}
187+
188+
if task.Edges.Agent == nil {
189+
return nil, nil, fmt.Errorf("no agent associated with task: %s", taskID)
190+
}
191+
192+
return task, task.Edges.Agent, nil
193+
}
194+
195+
func (g *TitleGenerator) buildMessageHistory(messages []*memory.Message) ([]*model.Message, error) {
196+
modelMessages := make([]*model.Message, 0, len(messages))
197+
198+
for _, msg := range messages {
199+
if msg.Source == types.MessageSourceUser || msg.Source == types.MessageSourceAssistant {
200+
modelMsg, err := ConvertMemoryMessageToModel(msg)
201+
if err != nil {
202+
return nil, err
203+
}
204+
modelMessages = append(modelMessages, modelMsg)
205+
}
206+
}
207+
208+
return modelMessages, nil
209+
}
210+
211+
func extractTitle(content []model.ContentBlock) string {
212+
var title string
213+
for _, block := range content {
214+
if textBlock, ok := block.(*model.TextBlock); ok {
215+
title = textBlock.Text
216+
break
217+
}
218+
}
219+
220+
title = strings.Trim(title, "\" \n\t\r'`")
221+
title = strings.TrimSpace(title)
222+
223+
return title
224+
}
225+
226+
func hasUserMessage(messages []*memory.Message) bool {
227+
for _, msg := range messages {
228+
if msg.Source == types.MessageSourceUser {
229+
return true
230+
}
231+
}
232+
return false
233+
}

backend/api/conv/task.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ func ConvertTaskSpecToProto(t *memory.Task) (*v1.TaskSpec, error) {
3232
AgentId: strPtr(t.AgentID.String()),
3333
Workspace: t.ProjectDirectory,
3434
DesiredPhase: ConvertTaskPhaseToProto(t.DesiredPhase),
35+
Description: t.Description,
3536
}, nil
3637
}
3738

backend/api/task.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,15 @@ func (h *TaskHandler) CreateTask(ctx context.Context, req *connect.Request[v1.Cr
5454
return nil, err
5555
}
5656

57-
return tx.Task.Create().
57+
taskCreate := tx.Task.Create().
5858
SetAgentID(agentID).
59-
SetProjectDirectory(req.Msg.ProjectDirectory).
60-
Save(ctx)
59+
SetProjectDirectory(req.Msg.ProjectDirectory)
60+
61+
if req.Msg.Description != "" {
62+
taskCreate = taskCreate.SetDescription(req.Msg.Description)
63+
}
64+
65+
return taskCreate.Save(ctx)
6166
})
6267

6368
if err != nil {

backend/go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ require (
2424
github.com/spf13/afero v1.14.0
2525
github.com/tink-crypto/tink-go v0.0.0-20230613075026-d6de17e3f164
2626
github.com/zalando/go-keyring v0.2.6
27+
golang.org/x/sync v0.17.0
2728
google.golang.org/genai v1.21.0
2829
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1
2930
google.golang.org/protobuf v1.36.8

backend/go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2112,8 +2112,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
21122112
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
21132113
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
21142114
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
2115-
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
2116-
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
2115+
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
2116+
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
21172117
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
21182118
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
21192119
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

backend/memory/migrate/schema.go

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)