mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add experimental agents support (#22290)
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>
This commit is contained in:
@@ -3,6 +3,7 @@ package dbcrypt
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
@@ -82,6 +83,32 @@ func Rotate(ctx context.Context, log slog.Logger, sqlDB *sql.DB, ciphers []Ciphe
|
||||
log.Debug(ctx, "encrypted user tokens", slog.F("user_id", uid), slog.F("current", idx+1), slog.F("cipher", ciphers[0].HexDigest()))
|
||||
}
|
||||
|
||||
providers, err := cryptDB.GetChatProviders(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get chat providers: %w", err)
|
||||
}
|
||||
log.Info(ctx, "encrypting chat provider keys", slog.F("provider_count", len(providers)))
|
||||
for idx, provider := range providers {
|
||||
if strings.TrimSpace(provider.APIKey) == "" {
|
||||
continue
|
||||
}
|
||||
if provider.ApiKeyKeyID.Valid && provider.ApiKeyKeyID.String == ciphers[0].HexDigest() {
|
||||
log.Debug(ctx, "skipping chat provider", slog.F("provider", provider.Provider), slog.F("current", idx+1), slog.F("cipher", ciphers[0].HexDigest()))
|
||||
continue
|
||||
}
|
||||
if _, err := cryptDB.UpdateChatProvider(ctx, database.UpdateChatProviderParams{
|
||||
DisplayName: provider.DisplayName,
|
||||
APIKey: provider.APIKey,
|
||||
BaseUrl: provider.BaseUrl,
|
||||
ApiKeyKeyID: sql.NullString{}, // dbcrypt will update as required
|
||||
Enabled: provider.Enabled,
|
||||
ID: provider.ID,
|
||||
}); err != nil {
|
||||
return xerrors.Errorf("update chat provider id=%s provider=%s: %w", provider.ID, provider.Provider, err)
|
||||
}
|
||||
log.Debug(ctx, "encrypted chat provider key", slog.F("provider", provider.Provider), slog.F("current", idx+1), slog.F("cipher", ciphers[0].HexDigest()))
|
||||
}
|
||||
|
||||
// Revoke old keys
|
||||
for _, c := range ciphers[1:] {
|
||||
if err := db.RevokeDBCryptKey(ctx, c.HexDigest()); err != nil {
|
||||
@@ -172,6 +199,28 @@ func Decrypt(ctx context.Context, log slog.Logger, sqlDB *sql.DB, ciphers []Ciph
|
||||
log.Debug(ctx, "decrypted user tokens", slog.F("user_id", uid), slog.F("current", idx+1), slog.F("cipher", ciphers[0].HexDigest()))
|
||||
}
|
||||
|
||||
providers, err := cryptDB.GetChatProviders(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get chat providers: %w", err)
|
||||
}
|
||||
log.Info(ctx, "decrypting chat provider keys", slog.F("provider_count", len(providers)))
|
||||
for idx, provider := range providers {
|
||||
if !provider.ApiKeyKeyID.Valid {
|
||||
continue
|
||||
}
|
||||
if _, err := cryptDB.UpdateChatProvider(ctx, database.UpdateChatProviderParams{
|
||||
DisplayName: provider.DisplayName,
|
||||
APIKey: provider.APIKey,
|
||||
BaseUrl: provider.BaseUrl,
|
||||
ApiKeyKeyID: sql.NullString{}, // we explicitly want to clear the key id
|
||||
Enabled: provider.Enabled,
|
||||
ID: provider.ID,
|
||||
}); err != nil {
|
||||
return xerrors.Errorf("update chat provider id=%s provider=%s: %w", provider.ID, provider.Provider, err)
|
||||
}
|
||||
log.Debug(ctx, "decrypted chat provider key", slog.F("provider", provider.Provider), slog.F("current", idx+1), slog.F("cipher", ciphers[0].HexDigest()))
|
||||
}
|
||||
|
||||
// Revoke _all_ keys
|
||||
for _, c := range ciphers {
|
||||
if err := db.RevokeDBCryptKey(ctx, c.HexDigest()); err != nil {
|
||||
@@ -192,6 +241,10 @@ DELETE FROM user_links
|
||||
DELETE FROM external_auth_links
|
||||
WHERE oauth_access_token_key_id IS NOT NULL
|
||||
OR oauth_refresh_token_key_id IS NOT NULL;
|
||||
UPDATE chat_providers
|
||||
SET api_key = '',
|
||||
api_key_key_id = NULL
|
||||
WHERE api_key_key_id IS NOT NULL;
|
||||
COMMIT;
|
||||
`
|
||||
|
||||
@@ -203,9 +256,9 @@ func Delete(ctx context.Context, log slog.Logger, sqlDB *sql.DB) error {
|
||||
store := database.New(sqlDB)
|
||||
_, err := sqlDB.ExecContext(ctx, sqlDeleteEncryptedUserTokens)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("delete user links: %w", err)
|
||||
return xerrors.Errorf("delete encrypted tokens and chat provider keys: %w", err)
|
||||
}
|
||||
log.Info(ctx, "deleted encrypted user tokens")
|
||||
log.Info(ctx, "deleted encrypted user tokens and chat provider API keys")
|
||||
|
||||
log.Info(ctx, "revoking all active keys")
|
||||
keys, err := store.GetDBCryptKeys(ctx)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
@@ -351,6 +352,92 @@ func (db *dbCrypt) GetCryptoKeysByFeature(ctx context.Context, feature database.
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (db *dbCrypt) GetChatProviderByID(ctx context.Context, id uuid.UUID) (database.ChatProvider, error) {
|
||||
provider, err := db.Store.GetChatProviderByID(ctx, id)
|
||||
if err != nil {
|
||||
return database.ChatProvider{}, err
|
||||
}
|
||||
if err := db.decryptField(&provider.APIKey, provider.ApiKeyKeyID); err != nil {
|
||||
return database.ChatProvider{}, err
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func (db *dbCrypt) GetChatProviderByProvider(ctx context.Context, providerName string) (database.ChatProvider, error) {
|
||||
provider, err := db.Store.GetChatProviderByProvider(ctx, providerName)
|
||||
if err != nil {
|
||||
return database.ChatProvider{}, err
|
||||
}
|
||||
if err := db.decryptField(&provider.APIKey, provider.ApiKeyKeyID); err != nil {
|
||||
return database.ChatProvider{}, err
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func (db *dbCrypt) GetChatProviders(ctx context.Context) ([]database.ChatProvider, error) {
|
||||
providers, err := db.Store.GetChatProviders(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range providers {
|
||||
if err := db.decryptField(&providers[i].APIKey, providers[i].ApiKeyKeyID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return providers, nil
|
||||
}
|
||||
|
||||
func (db *dbCrypt) GetEnabledChatProviders(ctx context.Context) ([]database.ChatProvider, error) {
|
||||
providers, err := db.Store.GetEnabledChatProviders(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range providers {
|
||||
if err := db.decryptField(&providers[i].APIKey, providers[i].ApiKeyKeyID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return providers, nil
|
||||
}
|
||||
|
||||
func (db *dbCrypt) InsertChatProvider(ctx context.Context, params database.InsertChatProviderParams) (database.ChatProvider, error) {
|
||||
if strings.TrimSpace(params.APIKey) == "" {
|
||||
params.ApiKeyKeyID = sql.NullString{}
|
||||
} else if err := db.encryptField(¶ms.APIKey, ¶ms.ApiKeyKeyID); err != nil {
|
||||
return database.ChatProvider{}, err
|
||||
}
|
||||
|
||||
provider, err := db.Store.InsertChatProvider(ctx, params)
|
||||
if err != nil {
|
||||
return database.ChatProvider{}, err
|
||||
}
|
||||
if err := db.decryptField(&provider.APIKey, provider.ApiKeyKeyID); err != nil {
|
||||
return database.ChatProvider{}, err
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func (db *dbCrypt) UpdateChatProvider(ctx context.Context, params database.UpdateChatProviderParams) (database.ChatProvider, error) {
|
||||
if strings.TrimSpace(params.APIKey) == "" {
|
||||
params.ApiKeyKeyID = sql.NullString{}
|
||||
} else if err := db.encryptField(¶ms.APIKey, ¶ms.ApiKeyKeyID); err != nil {
|
||||
return database.ChatProvider{}, err
|
||||
}
|
||||
|
||||
provider, err := db.Store.UpdateChatProvider(ctx, params)
|
||||
if err != nil {
|
||||
return database.ChatProvider{}, err
|
||||
}
|
||||
if err := db.decryptField(&provider.APIKey, provider.ApiKeyKeyID); err != nil {
|
||||
return database.ChatProvider{}, err
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func (db *dbCrypt) encryptField(field *string, digest *sql.NullString) error {
|
||||
// If no cipher is loaded, then we can't encrypt anything!
|
||||
if db.ciphers == nil || db.primaryCipherDigest == "" {
|
||||
|
||||
Reference in New Issue
Block a user