diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 3260ba9eec..86713b8201 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -403,6 +403,16 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { return } + // Validate per-chat system prompt length. + const maxSystemPromptLen = 10000 + if len(req.SystemPrompt) > maxSystemPromptLen { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "System prompt exceeds maximum length.", + Detail: fmt.Sprintf("System prompt must be at most %d characters, got %d.", maxSystemPromptLen, len(req.SystemPrompt)), + }) + return + } + contentBlocks, titleSource, inputError := createChatInputFromRequest(ctx, api.Database, req) if inputError != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, *inputError) @@ -483,7 +493,7 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { WorkspaceID: workspaceSelection.WorkspaceID, Title: title, ModelConfigID: modelConfigID, - SystemPrompt: api.resolvedChatSystemPrompt(ctx), + SystemPrompt: req.SystemPrompt, InitialUserContent: contentBlocks, MCPServerIDs: mcpServerIDs, Labels: labels, @@ -3477,35 +3487,6 @@ func (api *API) deleteUserChatCompactionThreshold(rw http.ResponseWriter, r *htt rw.WriteHeader(http.StatusNoContent) } -func (api *API) resolvedChatSystemPrompt(ctx context.Context) string { - config, err := api.Database.GetChatSystemPromptConfig(ctx) - if err != nil { - // We intentionally fail open here. When the prompt configuration - // cannot be read, returning the built-in default keeps the chat - // grounded instead of sending no system guidance at all. - api.Logger.Error(ctx, "failed to fetch chat system prompt configuration, using default", slog.Error(err)) - return chatd.DefaultSystemPrompt - } - - sanitizedCustom := chatd.SanitizePromptText(config.ChatSystemPrompt) - if sanitizedCustom == "" && strings.TrimSpace(config.ChatSystemPrompt) != "" { - api.Logger.Warn(ctx, "custom system prompt became empty after sanitization, omitting custom portion") - } - - var parts []string - if config.IncludeDefaultSystemPrompt { - parts = append(parts, chatd.DefaultSystemPrompt) - } - if sanitizedCustom != "" { - parts = append(parts, sanitizedCustom) - } - result := strings.Join(parts, "\n\n") - if result == "" { - api.Logger.Warn(ctx, "resolved system prompt is empty, no system prompt will be injected into chats") - } - return result -} - func (api *API) postChatFile(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 07df3f2f23..65316dc02d 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -313,6 +313,111 @@ func TestPostChats(t *testing.T) { } }) + t.Run("WithPerChatSystemPrompt", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "hello with system prompt", + }, + }, + SystemPrompt: "You are a Go expert.", + }) + require.NoError(t, err) + require.NotEqual(t, uuid.Nil, chat.ID) + + // Use the DB directly to see system messages, which are + // hidden from the public API. + dbMessages, err := db.GetChatMessagesForPromptByChatID(dbauthz.AsSystemRestricted(ctx), chat.ID) + require.NoError(t, err) + + // Expect: deployment system prompt, per-chat system prompt, + // workspace awareness, user message. + var systemMessages []database.ChatMessage + for _, msg := range dbMessages { + if msg.Role == database.ChatMessageRoleSystem { + systemMessages = append(systemMessages, msg) + } + } + require.GreaterOrEqual(t, len(systemMessages), 2, + "expected at least deployment + per-chat system messages") + + // The per-chat system prompt should be the second system + // message and contain the user-specified text. + foundPerChat := false + for _, msg := range systemMessages { + if msg.Content.Valid { + raw := string(msg.Content.RawMessage) + if strings.Contains(raw, "You are a Go expert.") { + foundPerChat = true + break + } + } + } + require.True(t, foundPerChat, + "per-chat system prompt not found in system messages") + }) + + t.Run("PerChatSystemPromptEmpty", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "hello without system prompt", + }, + }, + SystemPrompt: "", + }) + require.NoError(t, err) + + dbMessages, err := db.GetChatMessagesForPromptByChatID(dbauthz.AsSystemRestricted(ctx), chat.ID) + require.NoError(t, err) + + // No per-chat system prompt should be present. + for _, msg := range dbMessages { + if msg.Role == database.ChatMessageRoleSystem && msg.Content.Valid { + raw := string(msg.Content.RawMessage) + require.NotContains(t, raw, "You are a Go expert.", + "unexpected per-chat system prompt in messages") + } + } + }) + + t.Run("PerChatSystemPromptTooLong", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + longPrompt := strings.Repeat("a", 10001) + _, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "hello", + }, + }, + SystemPrompt: longPrompt, + }) + requireSDKError(t, err, http.StatusBadRequest) + }) + t.Run("WorkspaceNotAccessible", func(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 57659670ba..92b79b675d 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -886,7 +886,8 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C return xerrors.Errorf("insert chat: %w", err) } - systemPrompt := strings.TrimSpace(opts.SystemPrompt) + deploymentPrompt := p.resolveDeploymentSystemPrompt(ctx) + userPrompt := SanitizePromptText(opts.SystemPrompt) var workspaceAwareness string if opts.WorkspaceID.Valid { workspaceAwareness = "This chat is attached to a workspace. You can use workspace tools like execute, read_file, write_file, etc." @@ -908,16 +909,32 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C ChatID: insertedChat.ID, } - if systemPrompt != "" { - systemContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ - codersdk.ChatMessageText(systemPrompt), + if deploymentPrompt != "" { + deploymentContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ + codersdk.ChatMessageText(deploymentPrompt), }) if err != nil { - return xerrors.Errorf("marshal system prompt: %w", err) + return xerrors.Errorf("marshal deployment system prompt: %w", err) } appendChatMessage(&msgParams, newChatMessage( database.ChatMessageRoleSystem, - systemContent, + deploymentContent, + database.ChatMessageVisibilityModel, + opts.ModelConfigID, + chatprompt.CurrentContentVersion, + )) + } + + if userPrompt != "" { + userPromptContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ + codersdk.ChatMessageText(userPrompt), + }) + if err != nil { + return xerrors.Errorf("marshal user system prompt: %w", err) + } + appendChatMessage(&msgParams, newChatMessage( + database.ChatMessageRoleSystem, + userPromptContent, database.ChatMessageVisibilityModel, opts.ModelConfigID, chatprompt.CurrentContentVersion, @@ -5229,6 +5246,37 @@ func (p *Server) resolveUserCompactionThreshold(ctx context.Context, userID uuid return int32(val), true } +// resolveDeploymentSystemPrompt builds the deployment-level system +// prompt from the built-in default and the admin-configured custom +// prompt stored in site_configs. +func (p *Server) resolveDeploymentSystemPrompt(ctx context.Context) string { + config, err := p.db.GetChatSystemPromptConfig(ctx) + if err != nil { + // Fail open: use the built-in default so chats always have + // some system guidance. + p.logger.Error(ctx, "failed to fetch chat system prompt configuration, using default", slog.Error(err)) + return DefaultSystemPrompt + } + + sanitizedCustom := SanitizePromptText(config.ChatSystemPrompt) + if sanitizedCustom == "" && strings.TrimSpace(config.ChatSystemPrompt) != "" { + p.logger.Warn(ctx, "custom system prompt became empty after sanitization, omitting custom portion") + } + + var parts []string + if config.IncludeDefaultSystemPrompt { + parts = append(parts, DefaultSystemPrompt) + } + if sanitizedCustom != "" { + parts = append(parts, sanitizedCustom) + } + result := strings.Join(parts, "\n\n") + if result == "" { + p.logger.Warn(ctx, "resolved system prompt is empty, no system prompt will be injected into chats") + } + return result +} + // resolveUserPrompt fetches the user's custom chat prompt from the // database and wraps it in tags. Returns empty // string if no prompt is set. diff --git a/coderd/x/chatd/subagent_test.go b/coderd/x/chatd/subagent_test.go index 98b012514e..69ed10e6b5 100644 --- a/coderd/x/chatd/subagent_test.go +++ b/coderd/x/chatd/subagent_test.go @@ -1,6 +1,7 @@ package chatd_test import ( + "strings" "testing" "github.com/google/uuid" @@ -110,19 +111,19 @@ func TestSpawnComputerUseAgent_SystemPromptFormat(t *testing.T) { // The system message raw content is a JSON-encoded string. // It should contain the system prompt with the user prompt. - var rawSystemContent string + var foundPrompt bool for _, msg := range messages { if msg.Role != "system" { continue } - if msg.Content.Valid { - rawSystemContent = string(msg.Content.RawMessage) + if msg.Content.Valid && strings.Contains(string(msg.Content.RawMessage), prompt) { + foundPrompt = true break } } - assert.Contains(t, rawSystemContent, prompt, - "system prompt raw content should contain the user prompt") + assert.True(t, foundPrompt, + "at least one system message should contain the user prompt") } func TestSpawnComputerUseAgent_ChildIsListedUnderParent(t *testing.T) { diff --git a/codersdk/chats.go b/codersdk/chats.go index af69c03084..1a4a865d08 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -345,6 +345,7 @@ type ChatInputPart struct { // CreateChatRequest is the request to create a new chat. type CreateChatRequest struct { Content []ChatInputPart `json:"content"` + SystemPrompt string `json:"system_prompt,omitempty"` WorkspaceID *uuid.UUID `json:"workspace_id,omitempty" format:"uuid"` ModelConfigID *uuid.UUID `json:"model_config_id,omitempty" format:"uuid"` MCPServerIDs []uuid.UUID `json:"mcp_server_ids,omitempty" format:"uuid"` diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index e14e2a8c26..f502de0395 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2386,6 +2386,7 @@ export interface CreateChatProviderConfigRequest { */ export interface CreateChatRequest { readonly content: readonly ChatInputPart[]; + readonly system_prompt?: string; readonly workspace_id?: string; readonly model_config_id?: string; readonly mcp_server_ids?: readonly string[];