Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion e2e/testdata/cassettes/TestRuntime_Mistral_Basic.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ interactions:
proto_minor: 1
content_length: 0
host: api.mistral.ai
body: "{\"messages\":[{\"content\":\"You are a helpful AI assistant that generates concise, descriptive titles for conversations. You will be given a conversation history and asked to create a title that captures the main topic.\",\"role\":\"system\"},{\"content\":\"Based on the following message a user sent to an AI assistant, generate a short, descriptive title (maximum 50 characters) that captures the main topic or purpose of the conversation. Return ONLY the title text, nothing else.\\n\\nUser message: What's 2+2?\\n\\n\",\"role\":\"user\"}],\"model\":\"mistral-small\",\"stream_options\":{\"include_usage\":true},\"stream\":true}"
body: "{\"messages\":[{\"content\":\"You are a helpful AI assistant that generates concise, descriptive titles for conversations. You will be given a conversation history and asked to create a title that captures the main topic.\",\"role\":\"system\"},{\"content\":\"Based on the following message a user sent to an AI assistant, generate a short, descriptive title (maximum 50 characters) that captures the main topic or purpose of the conversation. Return ONLY the title text, nothing else.\\n\\nUser message: What's 2+2?\\n\\n\",\"role\":\"user\"}],\"model\":\"mistral-small\",\"max_tokens\":100,\"stream_options\":{\"include_usage\":true},\"stream\":true}"
url: https://api.mistral.ai/v1/chat/completions
method: POST
response:
Expand Down
2 changes: 1 addition & 1 deletion e2e/testdata/cassettes/TestRuntime_OpenAI_Basic.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ interactions:
proto_minor: 1
content_length: 0
host: api.openai.com
body: "{\"messages\":[{\"content\":\"You are a helpful AI assistant that generates concise, descriptive titles for conversations. You will be given a conversation history and asked to create a title that captures the main topic.\",\"role\":\"system\"},{\"content\":\"Based on the following message a user sent to an AI assistant, generate a short, descriptive title (maximum 50 characters) that captures the main topic or purpose of the conversation. Return ONLY the title text, nothing else.\\n\\nUser message: What's 2+2?\\n\\n\",\"role\":\"user\"}],\"model\":\"gpt-3.5-turbo\",\"stream_options\":{\"include_usage\":true},\"stream\":true}"
body: "{\"messages\":[{\"content\":\"You are a helpful AI assistant that generates concise, descriptive titles for conversations. You will be given a conversation history and asked to create a title that captures the main topic.\",\"role\":\"system\"},{\"content\":\"Based on the following message a user sent to an AI assistant, generate a short, descriptive title (maximum 50 characters) that captures the main topic or purpose of the conversation. Return ONLY the title text, nothing else.\\n\\nUser message: What's 2+2?\\n\\n\",\"role\":\"user\"}],\"model\":\"gpt-3.5-turbo\",\"max_tokens\":100,\"stream_options\":{\"include_usage\":true},\"stream\":true}"
url: https://api.openai.com/v1/chat/completions
method: POST
response:
Expand Down
13 changes: 12 additions & 1 deletion pkg/model/provider/clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,18 @@ func CloneWithOptions(ctx context.Context, base Provider, opts ...options.Opt) P
mergedOpts := append(baseOpts, opts...)
mergedOpts = append(mergedOpts, options.WithGeneratingTitle())

clone, err := New(ctx, &config.ModelConfig, config.Env, mergedOpts...)
// Apply max_tokens override if present in options
// We need to apply it to the ModelConfig itself since that's what providers use
modelConfig := config.ModelConfig
for _, opt := range mergedOpts {
tempOpts := &options.ModelOptions{}
opt(tempOpts)
if maxTokens := tempOpts.MaxTokens(); maxTokens != nil {
modelConfig.MaxTokens = *maxTokens
}
}

clone, err := New(ctx, &modelConfig, config.Env, mergedOpts...)
if err != nil {
slog.Debug("Failed to clone provider; using base provider", "error", err, "id", base.ID())
return base
Expand Down
14 changes: 14 additions & 0 deletions pkg/model/provider/options/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type ModelOptions struct {
gateway string
structuredOutput *latest.StructuredOutput
generatingTitle bool
maxTokens *int
}

func (c *ModelOptions) Gateway() string {
Expand All @@ -22,6 +23,10 @@ func (c *ModelOptions) GeneratingTitle() bool {
return c.generatingTitle
}

func (c *ModelOptions) MaxTokens() *int {
return c.maxTokens
}

type Opt func(*ModelOptions)

func WithGateway(gateway string) Opt {
Expand All @@ -42,6 +47,12 @@ func WithGeneratingTitle() Opt {
}
}

func WithMaxTokens(maxTokens int) Opt {
return func(cfg *ModelOptions) {
cfg.maxTokens = &maxTokens
}
}

// FromModelOptions converts a concrete ModelOptions value into a slice of
// Opt configuration functions. Later Opts override earlier ones when applied.
func FromModelOptions(m ModelOptions) []Opt {
Expand All @@ -55,5 +66,8 @@ func FromModelOptions(m ModelOptions) []Opt {
if m.generatingTitle {
out = append(out, WithGeneratingTitle())
}
if m.maxTokens != nil {
out = append(out, WithMaxTokens(*m.maxTokens))
}
return out
}
16 changes: 15 additions & 1 deletion pkg/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -1295,6 +1295,18 @@ func (r *LocalRuntime) handleHandoff(ctx context.Context, sess *session.Session,
}, nil
}

// truncateTitle truncates a title to maxLength characters, adding an ellipsis if needed
func truncateTitle(title string, maxLength int) string {
if len(title) <= maxLength {
return title
}
// Ensure we have room for the ellipsis
if maxLength < 3 {
return "..."
}
return title[:maxLength-3] + "..."
}

// generateSessionTitle generates a title for the session based on the first user message
func (r *LocalRuntime) generateSessionTitle(ctx context.Context, sess *session.Session, events chan Event) {
slog.Debug("Generating title for session", "session_id", sess.ID)
Expand All @@ -1309,7 +1321,7 @@ func (r *LocalRuntime) generateSessionTitle(ctx context.Context, sess *session.S
systemPrompt := "You are a helpful AI assistant that generates concise, descriptive titles for conversations. You will be given a conversation history and asked to create a title that captures the main topic."
userPrompt := fmt.Sprintf("Based on the following message a user sent to an AI assistant, generate a short, descriptive title (maximum 50 characters) that captures the main topic or purpose of the conversation. Return ONLY the title text, nothing else.\n\nUser message: %s\n\n", firstUserMessage)

titleModel := provider.CloneWithOptions(ctx, r.CurrentAgent().Model(), options.WithStructuredOutput(nil))
titleModel := provider.CloneWithOptions(ctx, r.CurrentAgent().Model(), options.WithStructuredOutput(nil), options.WithMaxTokens(100))
newTeam := team.New(
team.WithID("title-generator"),
team.WithAgents(agent.New("root", systemPrompt, agent.WithModel(titleModel))),
Expand Down Expand Up @@ -1337,6 +1349,8 @@ func (r *LocalRuntime) generateSessionTitle(ctx context.Context, sess *session.S
if title == "" {
return
}
// Truncate title to 50 characters with ellipsis if needed
title = truncateTitle(title, 50)
sess.Title = title
slog.Debug("Generated session title", "session_id", sess.ID, "title", title)
events <- SessionTitle(sess.ID, title, r.currentAgent)
Expand Down
63 changes: 63 additions & 0 deletions pkg/runtime/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -879,3 +879,66 @@ func TestEmitStartupInfo(t *testing.T) {
// Should be empty due to deduplication
require.Empty(t, collectedEvents2, "EmitStartupInfo should not emit duplicate events")
}

func TestTruncateTitle(t *testing.T) {
tests := []struct {
name string
title string
maxLength int
expected string
}{
{
name: "title shorter than max length",
title: "Short title",
maxLength: 50,
expected: "Short title",
},
{
name: "title exactly at max length",
title: "This is exactly fifty characters in length now.",
maxLength: 50,
expected: "This is exactly fifty characters in length now.",
},
{
name: "title longer than max length",
title: "This is a very long title that exceeds the maximum character limit",
maxLength: 50,
expected: "This is a very long title that exceeds the maxi...",
},
{
name: "very short max length",
title: "Any title",
maxLength: 5,
expected: "An...",
},
{
name: "max length less than 3",
title: "Any title",
maxLength: 2,
expected: "...",
},
{
name: "empty title",
title: "",
maxLength: 50,
expected: "",
},
{
name: "title with unicode characters",
title: "こんにちは、これは日本語のタイトルです。とても長いタイトルなので切り捨てられるはずです。",
maxLength: 50,
expected: "こんにちは、これは日本語のタイトルです。とても長いタイトルなので切り捨てられるはずです。"[:47] + "...",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := truncateTitle(tt.title, tt.maxLength)
require.Equal(t, tt.expected, result)
// Only check length constraint if maxLength >= 3 (otherwise ellipsis alone is 3 chars)
if tt.maxLength >= 3 {
require.LessOrEqual(t, len(result), tt.maxLength)
}
})
}
}
Loading