mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
edee917d88
feat: add AI chat system with agent tools and chat UI Introduce the chatd subsystem and Agents UI for AI-powered chat within Coder workspaces. - Add chatd package with chat loop, message compaction, prompt management, and LLM provider integration (OpenAI, Anthropic) - Add agent tools: create workspace, list/read templates, read/write/ edit files, execute commands - Add chat API endpoints with streaming, message editing, and durable reconnection - Add database schema and migrations for chats, chat messages, chat providers, and chat model configs - Add RBAC policies and dbauthz enforcement for chat resources - Add Agents UI pages with conversation timeline, queued messages list, diff viewer, and model configuration panel - Add comprehensive test coverage including coderd integration tests, chatd unit tests, and Storybook stories - Gate feature behind experiments flag --------- Co-authored-by: Cian Johnston <cian@coder.com> Co-authored-by: Danielle Maywood <danielle@themaywoods.com> Co-authored-by: Jeremy Ruppel <jeremy@coder.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
210 lines
5.4 KiB
Go
210 lines
5.4 KiB
Go
package chatloop
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"time"
|
|
|
|
"charm.land/fantasy"
|
|
"golang.org/x/xerrors"
|
|
)
|
|
|
|
const (
|
|
defaultCompactionThresholdPercent = int32(70)
|
|
minCompactionThresholdPercent = int32(0)
|
|
maxCompactionThresholdPercent = int32(100)
|
|
|
|
defaultCompactionSummaryPrompt = "Summarize the current chat so a " +
|
|
"new assistant can continue seamlessly. Include the user's goals, " +
|
|
"decisions made, concrete technical details (files, commands, APIs), " +
|
|
"errors encountered and fixes, and open questions. Be dense and factual. " +
|
|
"Omit pleasantries and next-step suggestions."
|
|
defaultCompactionSystemSummaryPrefix = "Summary of earlier chat context:"
|
|
defaultCompactionTimeout = 90 * time.Second
|
|
)
|
|
|
|
type CompactionOptions struct {
|
|
ThresholdPercent int32
|
|
ContextLimit int64
|
|
SummaryPrompt string
|
|
SystemSummaryPrefix string
|
|
Timeout time.Duration
|
|
Persist func(context.Context, CompactionResult) error
|
|
OnError func(error)
|
|
}
|
|
|
|
type CompactionResult struct {
|
|
SystemSummary string
|
|
SummaryReport string
|
|
ThresholdPercent int32
|
|
UsagePercent float64
|
|
ContextTokens int64
|
|
ContextLimit int64
|
|
}
|
|
|
|
func maybeCompact(
|
|
ctx context.Context,
|
|
runOpts RunOptions,
|
|
runResult *fantasy.AgentResult,
|
|
) error {
|
|
if runResult == nil || runOpts.Compaction == nil {
|
|
return nil
|
|
}
|
|
|
|
config := *runOpts.Compaction
|
|
if config.Persist == nil {
|
|
return xerrors.New("compaction persist callback is required")
|
|
}
|
|
if strings.TrimSpace(config.SummaryPrompt) == "" {
|
|
config.SummaryPrompt = defaultCompactionSummaryPrompt
|
|
}
|
|
if strings.TrimSpace(config.SystemSummaryPrefix) == "" {
|
|
config.SystemSummaryPrefix = defaultCompactionSystemSummaryPrefix
|
|
}
|
|
if config.Timeout <= 0 {
|
|
config.Timeout = defaultCompactionTimeout
|
|
}
|
|
if config.ThresholdPercent < minCompactionThresholdPercent ||
|
|
config.ThresholdPercent > maxCompactionThresholdPercent {
|
|
config.ThresholdPercent = defaultCompactionThresholdPercent
|
|
}
|
|
|
|
if config.ThresholdPercent >= maxCompactionThresholdPercent {
|
|
return nil
|
|
}
|
|
if runOpts.MaxSteps > 0 && len(runResult.Steps) >= runOpts.MaxSteps {
|
|
lastStep := runResult.Steps[len(runResult.Steps)-1]
|
|
if lastStep.FinishReason == fantasy.FinishReasonToolCalls &&
|
|
len(lastStep.Content.ToolCalls()) > 0 {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
contextTokens := int64(0)
|
|
contextLimitFromMetadata := int64(0)
|
|
for i := len(runResult.Steps) - 1; i >= 0; i-- {
|
|
usage := runResult.Steps[i].Usage
|
|
total := int64(0)
|
|
hasContextTokens := false
|
|
|
|
if usage.InputTokens > 0 {
|
|
total += usage.InputTokens
|
|
hasContextTokens = true
|
|
}
|
|
if usage.CacheReadTokens > 0 {
|
|
total += usage.CacheReadTokens
|
|
hasContextTokens = true
|
|
}
|
|
if usage.CacheCreationTokens > 0 {
|
|
total += usage.CacheCreationTokens
|
|
hasContextTokens = true
|
|
}
|
|
if !hasContextTokens && usage.TotalTokens > 0 {
|
|
total = usage.TotalTokens
|
|
hasContextTokens = true
|
|
}
|
|
if !hasContextTokens || total <= 0 {
|
|
continue
|
|
}
|
|
|
|
contextTokens = total
|
|
metadataLimit := extractContextLimit(runResult.Steps[i].ProviderMetadata)
|
|
if metadataLimit.Valid && metadataLimit.Int64 > 0 {
|
|
contextLimitFromMetadata = metadataLimit.Int64
|
|
}
|
|
break
|
|
}
|
|
if contextTokens <= 0 {
|
|
return nil
|
|
}
|
|
|
|
contextLimit := contextLimitFromMetadata
|
|
if contextLimit <= 0 && config.ContextLimit > 0 {
|
|
contextLimit = config.ContextLimit
|
|
}
|
|
if contextLimit <= 0 && runOpts.ContextLimitFallback > 0 {
|
|
contextLimit = runOpts.ContextLimitFallback
|
|
}
|
|
if contextLimit <= 0 {
|
|
return nil
|
|
}
|
|
|
|
usagePercent := (float64(contextTokens) / float64(contextLimit)) * 100
|
|
if usagePercent < float64(config.ThresholdPercent) {
|
|
return nil
|
|
}
|
|
|
|
summary, err := generateCompactionSummary(
|
|
ctx,
|
|
runOpts.Model,
|
|
runOpts.Messages,
|
|
runResult.Steps,
|
|
config,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if summary == "" {
|
|
return nil
|
|
}
|
|
|
|
systemSummary := strings.TrimSpace(
|
|
config.SystemSummaryPrefix + "\n\n" + summary,
|
|
)
|
|
|
|
return config.Persist(ctx, CompactionResult{
|
|
SystemSummary: systemSummary,
|
|
SummaryReport: summary,
|
|
ThresholdPercent: config.ThresholdPercent,
|
|
UsagePercent: usagePercent,
|
|
ContextTokens: contextTokens,
|
|
ContextLimit: contextLimit,
|
|
})
|
|
}
|
|
|
|
func generateCompactionSummary(
|
|
ctx context.Context,
|
|
model fantasy.LanguageModel,
|
|
messages []fantasy.Message,
|
|
steps []fantasy.StepResult,
|
|
options CompactionOptions,
|
|
) (string, error) {
|
|
summaryPrompt := make([]fantasy.Message, 0, len(messages)+len(steps)+1)
|
|
summaryPrompt = append(summaryPrompt, messages...)
|
|
for _, step := range steps {
|
|
summaryPrompt = append(summaryPrompt, step.Messages...)
|
|
}
|
|
summaryPrompt = append(summaryPrompt, fantasy.Message{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{Text: options.SummaryPrompt},
|
|
},
|
|
})
|
|
toolChoice := fantasy.ToolChoiceNone
|
|
|
|
summaryCtx, cancel := context.WithTimeout(ctx, options.Timeout)
|
|
defer cancel()
|
|
|
|
response, err := model.Generate(summaryCtx, fantasy.Call{
|
|
Prompt: summaryPrompt,
|
|
ToolChoice: &toolChoice,
|
|
})
|
|
if err != nil {
|
|
return "", xerrors.Errorf("generate summary text: %w", err)
|
|
}
|
|
|
|
parts := make([]string, 0, len(response.Content))
|
|
for _, block := range response.Content {
|
|
textBlock, ok := fantasy.AsContentType[fantasy.TextContent](block)
|
|
if !ok {
|
|
continue
|
|
}
|
|
text := strings.TrimSpace(textBlock.Text)
|
|
if text == "" {
|
|
continue
|
|
}
|
|
parts = append(parts, text)
|
|
}
|
|
return strings.TrimSpace(strings.Join(parts, " ")), nil
|
|
}
|